fix(mobile): open editor for disconnected hosts (#12575)
This commit is contained in:
parent
2073f7eeb2
commit
d52df52eea
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue