diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 7670fec82..e04f730c6 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -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 = () => ( {} + 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() + }) +}) diff --git a/mobile/src/tasks/mobile-task-navigation.ts b/mobile/src/tasks/mobile-task-navigation.ts new file mode 100644 index 000000000..3537d5171 --- /dev/null +++ b/mobile/src/tasks/mobile-task-navigation.ts @@ -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) + } +} diff --git a/mobile/src/tasks/use-open-mobile-tasks.ts b/mobile/src/tasks/use-open-mobile-tasks.ts new file mode 100644 index 000000000..010e914d7 --- /dev/null +++ b/mobile/src/tasks/use-open-mobile-tasks.ts @@ -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() + const router = useRouter() + const pendingRef = useRef(null) + + useEffect( + () => () => { + pendingRef.current?.controller.cancel() + pendingRef.current = null + }, + [] + ) + + return useCallback( + (hostId, provider) => { + pendingRef.current = coordinateMobileTasksNavigation( + pendingRef.current, + navigation, + router, + hostId, + provider + ) + }, + [navigation, router] + ) +}