fix(mobile): mount host before opening tasks (#11853)

This commit is contained in:
Brennan Benson 2026-07-31 20:46:51 -07:00 committed by GitHub
parent ed00ab0f34
commit 4c03cdff72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 399 additions and 3 deletions

View File

@ -49,6 +49,7 @@ import {
normalizeVisibleTaskProviders,
type TaskProvider
} from '../src/tasks/mobile-task-providers'
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
import { useResponsiveLayout } from '../src/layout/responsive-layout'
import { createMobileSessionHref } from '../src/session/mobile-session-route'
@ -286,6 +287,7 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
const openMobileTasks = useOpenMobileTasks()
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
@ -601,10 +603,9 @@ export default function HomeScreen() {
if (!primaryConnectedHost) {
return
}
const suffix = provider ? `?taskSource=${provider}` : ''
router.push(`/h/${primaryConnectedHost.id}/tasks${suffix}`)
openMobileTasks(primaryConnectedHost.id, provider)
},
[primaryConnectedHost, router]
[openMobileTasks, primaryConnectedHost]
)
const renderTaskHomeCard = () => (
<Pressable

View File

@ -0,0 +1,206 @@
import { describe, expect, it, vi } from 'vitest'
import {
coordinateMobileTasksNavigation,
mobileTasksHostRoute,
navigateToMobileTasks,
type MobileTasksNavigationState
} from './mobile-task-navigation'
function navigationHarness(initialState: MobileTasksNavigationState) {
let stateListener = () => {}
let state = initialState
const unsubscribeState = vi.fn()
const navigation = {
addListener: vi.fn((_event: 'state', listener: () => void) => {
stateListener = listener
return unsubscribeState
}),
dispatch: vi.fn(),
getState: () => state
}
return {
navigation,
setState(nextState: MobileTasksNavigationState) {
state = nextState
stateListener()
},
unsubscribeState
}
}
describe('mobile task navigation', () => {
it('waits for the expected host commit before navigating to Tasks', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
navigateToMobileTasks(harness.navigation, { push }, 'host/1')
expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan(
push.mock.invocationCallOrder[0]!
)
expect(push).toHaveBeenCalledWith(mobileTasksHostRoute('host/1'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({
index: 1,
routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }]
})
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({
index: 1,
routes: [
{ name: 'index' },
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }]
}
}
]
})
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan(
harness.navigation.dispatch.mock.invocationCallOrder[0]!
)
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'REPLACE',
target: '/h',
source: 'host-index',
payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } }
})
})
it('ignores unrelated state events and preserves the provider', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear')
harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] })
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({
index: 0,
routes: [
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }]
}
}
]
})
expect(harness.navigation.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: '[hostId]/tasks',
params: { hostId: 'host-1', taskSource: 'linear' }
}
})
)
})
it('cleanup prevents a stale navigation from replacing', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1')
controller.cancel()
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
})
it('reuses a pending host push and applies the latest provider', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const first = coordinateMobileTasksNavigation(
null,
harness.navigation,
{ push },
'host-1',
'github'
)
const second = coordinateMobileTasksNavigation(
first,
harness.navigation,
{ push },
'host-1',
'linear'
)
harness.setState({
index: 0,
routes: [
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }]
}
}
]
})
expect(second).toBe(first)
expect(push).toHaveBeenCalledOnce()
expect(harness.navigation.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
payload: {
name: '[hostId]/tasks',
params: { hostId: 'host-1', taskSource: 'linear' }
}
})
)
})
it('keeps waiting through host setup and cleans up when navigation leaves', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1')
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
expect(harness.unsubscribeState).not.toHaveBeenCalled()
harness.setState({ index: 0, routes: [{ name: 'index' }] })
harness.setState({
index: 0,
routes: [
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host-1' } }]
}
}
]
})
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
})
it('unsubscribes when mounting the host throws synchronously', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const error = new Error('navigation failed')
expect(() =>
navigateToMobileTasks(
harness.navigation,
{
push: () => {
throw error
}
},
'host-1'
)
).toThrow(error)
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
})
})

View File

@ -0,0 +1,154 @@
import type { TaskProvider } from './mobile-task-providers'
export type MobileTasksHostRoute = `/h/${string}`
export type MobileTasksNavigationState = Readonly<{
key?: string
index: number
routes: readonly MobileTasksNavigationRoute[]
}>
export type MobileTasksNavigationRoute = Readonly<{
key?: string
name: string
params?: Readonly<{ hostId?: unknown }>
state?: MobileTasksNavigationState
}>
export type MobileTasksRootNavigation = {
addListener: (event: 'state', listener: () => void) => () => void
dispatch: (action: MobileTasksHostReplaceAction) => void
getState: () => MobileTasksNavigationState
}
export type MobileTasksHostReplaceAction = Readonly<{
type: 'REPLACE'
target: string
source: string
payload: Readonly<{
name: '[hostId]/tasks'
params: Readonly<{ hostId: string; taskSource?: TaskProvider }>
}>
}>
export type MobileTasksRouter = {
push: (route: MobileTasksHostRoute) => void
}
export type MobileTasksNavigationController = Readonly<{
cancel: () => void
isActive: () => boolean
selectProvider: (provider?: TaskProvider) => void
}>
export type PendingMobileTasksNavigation = Readonly<{
hostId: string
controller: MobileTasksNavigationController
}>
export function mobileTasksHostRoute(hostId: string): MobileTasksHostRoute {
return `/h/${encodeURIComponent(hostId)}`
}
function mountedHostStack(
state: MobileTasksNavigationState,
expectedHostId: string
): { key: string; routeKey: string } | null {
const hostContainer = state.routes[state.index]
const hostState = hostContainer?.state
const hostRoute = hostState?.routes[hostState.index]
if (
hostContainer?.name !== 'h' ||
!hostState?.key ||
hostRoute?.name !== '[hostId]/index' ||
!hostRoute.key ||
hostRoute.params?.hostId !== expectedHostId
) {
return null
}
return { key: hostState.key, routeKey: hostRoute.key }
}
export function navigateToMobileTasks(
navigation: MobileTasksRootNavigation,
router: MobileTasksRouter,
hostId: string,
provider?: TaskProvider
): MobileTasksNavigationController {
let active = true
let hostRouteSeen = false
let selectedProvider = provider
let unsubscribeState = () => {}
const dispose = () => {
if (!active) {
return
}
active = false
unsubscribeState()
}
// Why: cold Expo deep links resolve to index; target Tasks only after its HostStack exists.
const onState = () => {
if (!active) {
return
}
const state = navigation.getState()
const currentRoute = state.routes[state.index]
if (currentRoute?.name === 'h') {
hostRouteSeen = true
} else if (hostRouteSeen) {
dispose()
return
}
const hostStack = mountedHostStack(state, hostId)
if (!hostStack) {
return
}
dispose()
const action: MobileTasksHostReplaceAction = {
type: 'REPLACE',
target: hostStack.key,
source: hostStack.routeKey,
payload: {
name: '[hostId]/tasks',
params: selectedProvider ? { hostId, taskSource: selectedProvider } : { hostId }
}
}
navigation.dispatch(action)
}
try {
unsubscribeState = navigation.addListener('state', onState)
router.push(mobileTasksHostRoute(hostId))
} catch (error) {
dispose()
throw error
}
return {
cancel: dispose,
isActive: () => active,
selectProvider: (nextProvider) => {
if (active) {
selectedProvider = nextProvider
}
}
}
}
export function coordinateMobileTasksNavigation(
current: PendingMobileTasksNavigation | null,
navigation: MobileTasksRootNavigation,
router: MobileTasksRouter,
hostId: string,
provider?: TaskProvider
): PendingMobileTasksNavigation {
if (current?.hostId === hostId && current.controller.isActive()) {
current.controller.selectProvider(provider)
return current
}
current?.controller.cancel()
return {
hostId,
controller: navigateToMobileTasks(navigation, router, hostId, provider)
}
}

View File

@ -0,0 +1,35 @@
import { useCallback, useEffect, useRef } from 'react'
import { useNavigation, useRouter } from 'expo-router'
import {
coordinateMobileTasksNavigation,
type MobileTasksRootNavigation,
type PendingMobileTasksNavigation
} from './mobile-task-navigation'
import type { TaskProvider } from './mobile-task-providers'
export function useOpenMobileTasks(): (hostId: string, provider?: TaskProvider) => void {
const navigation = useNavigation<MobileTasksRootNavigation>()
const router = useRouter()
const pendingRef = useRef<PendingMobileTasksNavigation | null>(null)
useEffect(
() => () => {
pendingRef.current?.controller.cancel()
pendingRef.current = null
},
[]
)
return useCallback(
(hostId, provider) => {
pendingRef.current = coordinateMobileTasksNavigation(
pendingRef.current,
navigation,
router,
hostId,
provider
)
},
[navigation, router]
)
}