diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 57532a4a8..52f0647c8 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -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)} diff --git a/mobile/src/home-host-edit-navigation-source.test.ts b/mobile/src/home-host-edit-navigation-source.test.ts index 2f6242f7e..4597a138e 100644 --- a/mobile/src/home-host-edit-navigation-source.test.ts +++ b/mobile/src/home-host-edit-navigation-source.test.ts @@ -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/) }) }) diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts index 66ca63c5e..2e3e8a9f5 100644 --- a/mobile/src/transport/host-edit-navigation.test.ts +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -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() }) }) diff --git a/mobile/src/transport/host-edit-navigation.ts b/mobile/src/transport/host-edit-navigation.ts index 19dbf703d..765e36d7b 100644 --- a/mobile/src/transport/host-edit-navigation.ts +++ b/mobile/src/transport/host-edit-navigation.ts @@ -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) => 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 } } diff --git a/mobile/src/transport/use-open-mobile-host-edit.ts b/mobile/src/transport/use-open-mobile-host-edit.ts new file mode 100644 index 000000000..68c20c5ae --- /dev/null +++ b/mobile/src/transport/use-open-mobile-host-edit.ts @@ -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() + const router = useRouter() + const pendingRef = useRef(null) + + useEffect( + () => () => { + pendingRef.current?.cancel() + pendingRef.current = null + }, + [] + ) + + return useCallback( + (hostId) => { + pendingRef.current?.cancel() + pendingRef.current = navigateToMobileHostEdit(navigation, router, hostId) + }, + [navigation, router] + ) +}