fix(mobile): open editor for disconnected hosts (#12575)

This commit is contained in:
Brennan Benson 2026-08-04 14:45:11 -07:00 committed by GitHub
parent 2073f7eeb2
commit d52df52eea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 187 additions and 30 deletions

View File

@ -16,7 +16,7 @@ import {
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
@ -206,6 +206,7 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
const openMobileHostEdit = useOpenMobileHostEdit()
const openMobileTasks = useOpenMobileTasks()
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
@ -906,7 +907,7 @@ export default function HomeScreen() {
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: closeHostClient,
onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
onEdit: openMobileHostEdit,
onRemove: setConfirmRemove
})}
onClose={() => setActionTarget(null)}

View File

@ -5,8 +5,7 @@ const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'u
describe('Home host edit navigation wiring', () => {
it('uses the cold-navigator-safe edit transition', () => {
expect(homeSource).toMatch(
/onEdit:\s*\(hostId\)\s*=>\s*navigateToMobileHostEdit\(router,\s*hostId\)/
)
expect(homeSource).toMatch(/const openMobileHostEdit = useOpenMobileHostEdit\(\)/)
expect(homeSource).toMatch(/onEdit:\s*openMobileHostEdit/)
})
})

View File

@ -1,29 +1,95 @@
import { describe, expect, it, vi } from 'vitest'
import { mobileHostEditRoute, navigateToMobileHostEdit } from './host-edit-navigation'
import {
mobileHostEditHostRoute,
mobileHostEditRoute,
navigateToMobileHostEdit,
type MobileHostEditNavigationState
} from './host-edit-navigation'
describe('mobileHostEditRoute', () => {
it('keeps the dynamic host segment explicit for a cold host navigator', () => {
expect(mobileHostEditRoute('host-1')).toEqual({
pathname: '/h/[hostId]/edit',
params: { hostId: 'host-1' }
})
})
function navigationHarness(initialState: MobileHostEditNavigationState) {
let stateListener = () => {}
let state = initialState
const unsubscribeState = vi.fn()
const navigation = {
addListener: vi.fn((_event: 'state', listener: () => void) => {
stateListener = listener
return unsubscribeState
}),
getState: () => state
}
return {
navigation,
setState(nextState: MobileHostEditNavigationState) {
state = nextState
stateListener()
},
unsubscribeState
}
}
it('mounts a cold host navigator before replacing its index with edit', () => {
let nextFrame: FrameRequestCallback | null = null
describe('mobile host edit navigation', () => {
it('waits for the expected host route to commit before replacing it with Edit', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const replace = vi.fn()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
nextFrame = callback
return 1
})
navigateToMobileHostEdit({ push, replace }, 'host-1')
expect(push).toHaveBeenCalledWith('/h/host-1')
navigateToMobileHostEdit(harness.navigation, { push, replace }, 'host/1')
expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1'))
expect(replace).not.toHaveBeenCalled()
nextFrame?.(0)
expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host-1'))
vi.unstubAllGlobals()
harness.setState({
index: 1,
routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }]
})
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host/1'))
})
it('does not replace an unrelated host route', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const replace = vi.fn()
navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace }, 'host-1')
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
expect(replace).not.toHaveBeenCalled()
})
it('cancels a pending replacement when navigation leaves the host flow', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const replace = vi.fn()
const controller = navigateToMobileHostEdit(
harness.navigation,
{ push: vi.fn(), replace },
'host-1'
)
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
controller.cancel()
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(replace).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(() =>
navigateToMobileHostEdit(
harness.navigation,
{
push: () => {
throw error
},
replace: vi.fn()
},
'host-1'
)
).toThrow(error)
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
})
})

View File

@ -1,8 +1,31 @@
type HostEditRouter = {
export type MobileHostEditNavigationState = Readonly<{
index: number
routes: readonly MobileHostEditNavigationRoute[]
}>
export type MobileHostEditNavigationRoute = Readonly<{
name: string
params?: Readonly<{ hostId?: unknown }>
}>
export type MobileHostEditRootNavigation = {
addListener: (event: 'state', listener: () => void) => () => void
getState: () => MobileHostEditNavigationState
}
export type MobileHostEditRouter = {
push: (href: `/h/${string}`) => void
replace: (href: ReturnType<typeof mobileHostEditRoute>) => void
}
export type MobileHostEditNavigationController = Readonly<{
cancel: () => void
}>
export function mobileHostEditHostRoute(hostId: string): `/h/${string}` {
return `/h/${encodeURIComponent(hostId)}`
}
export function mobileHostEditRoute(hostId: string) {
return {
pathname: '/h/[hostId]/edit' as const,
@ -10,10 +33,49 @@ export function mobileHostEditRoute(hostId: string) {
}
}
export function navigateToMobileHostEdit(router: HostEditRouter, hostId: string): void {
// Why: a cold nested host navigator resolves a deep push to its index route.
router.push(`/h/${hostId}`)
requestAnimationFrame(() => {
export function navigateToMobileHostEdit(
navigation: MobileHostEditRootNavigation,
router: MobileHostEditRouter,
hostId: string
): MobileHostEditNavigationController {
let active = true
let hostRouteSeen = false
let unsubscribeState = () => {}
const dispose = () => {
if (!active) {
return
}
active = false
unsubscribeState()
}
// Why: cold Expo deep links resolve to index; target Edit after the host route commits.
const onState = () => {
if (!active) {
return
}
const state = navigation.getState()
const currentRoute = state.routes[state.index]
if (currentRoute?.name !== 'h') {
if (hostRouteSeen) {
dispose()
}
return
}
hostRouteSeen = true
if (currentRoute.params?.hostId !== hostId) {
return
}
dispose()
router.replace(mobileHostEditRoute(hostId))
})
}
try {
unsubscribeState = navigation.addListener('state', onState)
router.push(mobileHostEditHostRoute(hostId))
} catch (error) {
dispose()
throw error
}
return { cancel: dispose }
}

View File

@ -0,0 +1,29 @@
import { useCallback, useEffect, useRef } from 'react'
import { useNavigation, useRouter } from 'expo-router'
import {
navigateToMobileHostEdit,
type MobileHostEditNavigationController,
type MobileHostEditRootNavigation
} from './host-edit-navigation'
export function useOpenMobileHostEdit(): (hostId: string) => void {
const navigation = useNavigation<MobileHostEditRootNavigation>()
const router = useRouter()
const pendingRef = useRef<MobileHostEditNavigationController | null>(null)
useEffect(
() => () => {
pendingRef.current?.cancel()
pendingRef.current = null
},
[]
)
return useCallback(
(hostId) => {
pendingRef.current?.cancel()
pendingRef.current = navigateToMobileHostEdit(navigation, router, hostId)
},
[navigation, router]
)
}