diff --git a/mobile/app/hybrid.tsx b/mobile/app/hybrid.tsx index 16e562fcf5e..7f15ad67866 100644 --- a/mobile/app/hybrid.tsx +++ b/mobile/app/hybrid.tsx @@ -28,6 +28,7 @@ import { useMobileWebAppForegroundAuthority } from '../src/mobile-web/use-mobile import { useMobileWebHostCatalog } from '../src/mobile-web/use-mobile-web-host-catalog' import { mobileWebDiagnosticsStore } from '../src/mobile-web/mobile-web-diagnostics-store' import { useMobileWebBridgeRuntimeRef } from '../src/mobile-web/use-mobile-web-bridge-runtime-ref' +import { useMobileWebResumeRouteMemory } from '../src/mobile-web/use-mobile-web-resume-route-memory' import { useMobileWebHardwareBackHandoff } from '../src/mobile-web/use-mobile-web-hardware-back-handoff' import { MobileWebNativeRouteHandoff } from '../src/mobile-web/mobile-web-native-route-handoff' import { useMobileWebNavigationAuthority } from '../src/mobile-web/use-mobile-web-navigation-authority' @@ -49,7 +50,6 @@ export default function HybridScreen() { const viewRef = useRef(null) const activeSessionIdRef = useRef(undefined) const initializedSessionRef = useRef(undefined) - const resumeRouteRef = useRef({ kind: 'workspaceList' }) const healthDeadlineRef = useRef(new MobileWebHealthDeadline(10_000)) const brokerRef = useRef(null) const postInitRef = useRef<() => Promise>(() => Promise.resolve()) @@ -64,6 +64,7 @@ export default function HybridScreen() { const [brokerSessionId, setBrokerSessionId] = useState() const [hostedViewActive, setHostedViewActive] = useState(true) const selectHost = useCallback((hostId: string | undefined) => setSelectedHostId(hostId), []) + const resumeRoute = useMobileWebResumeRouteMemory(selectedHostId) const e2eHostId = useMobileWebE2eHostSelection(hosts, selectedHostId, selectHost) const { client, state } = useHostClient(selectedHostId) const closeHostClient = useForgetHostClient() @@ -139,10 +140,6 @@ export default function HybridScreen() { } }, [params.hostId]) - useEffect(() => { - resumeRouteRef.current = { kind: 'workspaceList' } - }, [session?.sessionId]) - // A view-epoch bump replaces the document, so every page-scoped grant retires with it. useEffect(() => { initializedSessionRef.current = undefined @@ -206,7 +203,7 @@ export default function HybridScreen() { onTerminalResync: (reason) => mobileWebDiagnosticsStore.terminalResync(selectedHost.id, reason), rememberRoute(route) { - resumeRouteRef.current = route + resumeRoute.remember(route) }, rememberHostRoute: coldResumeRoute.rememberHostRoute, randomBytes: ExpoCrypto.getRandomBytes @@ -216,6 +213,7 @@ export default function HybridScreen() { coldResumeRoute.rememberHostRoute, navigationAuthority, postToWeb, + resumeRoute, selectedHost?.deviceToken, selectedHost?.id, selectedHost?.publicKeyB64, @@ -260,10 +258,10 @@ export default function HybridScreen() { hostDisplayName: hostName, reconnectAttempts: reconnects, lastConnectedAt: lastConnected, - resumeRoute: resumeRouteRef.current, + resumeRoute: resumeRoute.current(), grants: [...MOBILE_WEB_PRODUCTION_GRANTS] }) - }, [hostName, lastConnected, onHealthTimeout, postToWeb, reconnects, session, state]) + }, [hostName, lastConnected, onHealthTimeout, postToWeb, reconnects, resumeRoute, session, state]) useEffect(() => { postInitRef.current = postInit }, [postInit]) @@ -320,13 +318,27 @@ export default function HybridScreen() { viewRef, routeHandoff: nativeRouteHandoffRef.current, setHostedViewActive, - navigateToTerminalSettings: () => router.push('/terminal-settings'), - onNavigationFailure: () => showWarning('Couldn’t open Terminal settings.') + navigateToNativeRoute: (destination) => { + if (destination === 'connectionLog') { + router.push({ + pathname: '/connection-log', + params: { hostId: selectedHostId ?? '' } + }) + return + } + router.push('/terminal-settings') + }, + onNavigationFailure: (destination) => + showWarning( + destination === 'connectionLog' + ? 'Couldn’t open network diagnostics.' + : 'Couldn’t open Terminal settings.' + ) }) } } }, - [hardwareBackHandoff, markHealthy, postInit, router, session, showWarning] + [hardwareBackHandoff, markHealthy, postInit, router, selectedHostId, session, showWarning] ) const shellContext = useMemo( () => (session ? { sessionId: session.sessionId, buildId: session.buildId } : null), @@ -334,9 +346,10 @@ export default function HybridScreen() { ) const getBroker = useCallback(() => brokerRef.current, []) - const rememberRoute = useCallback((route: MobileWebResumeRoute) => { - resumeRouteRef.current = route - }, []) + const rememberRoute = useCallback( + (route: MobileWebResumeRoute) => resumeRoute.remember(route), + [resumeRoute] + ) useMobileWebNavigationIntentHandoff({ hosts, hostsLoading, diff --git a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts index 54e48b6625f..89c45ab2690 100644 --- a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts +++ b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts @@ -70,9 +70,9 @@ describe('mobile native shell route ownership', () => { } }) - it('opens terminal settings in the shell without clearing the hosted session', () => { + it('opens shell-owned screens without clearing the hosted session', () => { const settingsBranch = navigationAuthority.match( - /if \(destination === 'terminalSettings'\) \{([\s\S]*?)\n\s*\}/ + /if \(isMobileWebNativeRoute\(destination\)\) \{([\s\S]*?)\n\s*\}/ )?.[1] expect(settingsBranch).toContain('routeHandoffRef.current.record(requestId, destination)') expect(hybridShell).toContain('routeHandoffRef: nativeRouteHandoffRef') @@ -82,6 +82,7 @@ describe('mobile native shell route ownership', () => { expect(brokerMessageHandoff).toContain('await view.deactivateSessionView()') expect(brokerMessageHandoff).toContain('setHostedViewActive: args.setHostedViewActive') expect(hybridShell).toContain("router.push('/terminal-settings')") + expect(hybridShell).toContain("pathname: '/connection-log'") expect(hybridShell).toContain('void view.activateSessionView(sessionId)') expect(hybridShell).toContain('return () => setHostedViewActive(false)') expect(settingsBranch).not.toContain('clearRoute') @@ -95,6 +96,12 @@ describe('mobile native shell route ownership', () => { expect(hybridShell).toContain('postInitRef.current = postInit') }) + it('replays the hosted route across a package swap instead of resetting per session', () => { + expect(hybridShell).toContain('useMobileWebResumeRouteMemory(selectedHostId)') + expect(hybridShell).toContain('resumeRoute: resumeRoute.current()') + expect(hybridShell).not.toMatch(/resumeRoute[\s\S]{0,80}\}, \[session\?\.sessionId\]\)/) + }) + it('does not echo init after the hosted page acknowledges it', () => { const readyBranch = hybridShell.match( /if \(parsed\.value\.type === 'ready'\) \{([\s\S]*?)\} else if/ diff --git a/mobile/src/mobile-web/use-mobile-web-resume-route-memory.test.ts b/mobile/src/mobile-web/use-mobile-web-resume-route-memory.test.ts new file mode 100644 index 00000000000..60b1b1bc8a1 --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-resume-route-memory.test.ts @@ -0,0 +1,80 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract' +import { + useMobileWebResumeRouteMemory, + type MobileWebResumeRouteMemory +} from './use-mobile-web-resume-route-memory' + +const SESSION_ROUTE: MobileWebResumeRoute = { + kind: 'session', + workspaceId: 'repo::wt', + workspaceName: 'Workspace one' +} + +describe('useMobileWebResumeRouteMemory', () => { + let renderer: ReactTestRenderer | null = null + let memory: MobileWebResumeRouteMemory | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + renderer = null + memory = null + }) + + afterEach(() => { + act(() => renderer?.unmount()) + }) + + function Harness(props: { hostId: string | undefined; shellSessionId: string | undefined }) { + memory = useMobileWebResumeRouteMemory(props.hostId) + // Why: a package swap mints a new shell session, so the harness re-renders like the shell does. + void props.shellSessionId + return null + } + + function render(hostId: string | undefined, shellSessionId: string | undefined): void { + act(() => { + const element = createElement(Harness, { hostId, shellSessionId }) + if (renderer) { + renderer.update(element) + } else { + renderer = create(element) + } + }) + } + + it('starts at the workspace list', () => { + render('host-1', undefined) + expect(memory?.current()).toEqual({ kind: 'workspaceList' }) + }) + + it('replays the remembered route after a package swap mints a new shell session', () => { + render('host-1', 'session-1') + act(() => memory?.remember(SESSION_ROUTE)) + + render('host-1', 'session-2') + + expect(memory?.current()).toEqual(SESSION_ROUTE) + }) + + it('forgets a route belonging to another host', () => { + render('host-1', 'session-1') + act(() => memory?.remember(SESSION_ROUTE)) + + render('host-2', 'session-2') + + expect(memory?.current()).toEqual({ kind: 'workspaceList' }) + }) + + it('keeps the workspace list once the page navigates back to it', () => { + render('host-1', 'session-1') + act(() => memory?.remember(SESSION_ROUTE)) + act(() => memory?.remember({ kind: 'workspaceList' })) + + render('host-1', 'session-2') + + expect(memory?.current()).toEqual({ kind: 'workspaceList' }) + }) +}) diff --git a/mobile/src/mobile-web/use-mobile-web-resume-route-memory.ts b/mobile/src/mobile-web/use-mobile-web-resume-route-memory.ts new file mode 100644 index 00000000000..842210f3dac --- /dev/null +++ b/mobile/src/mobile-web/use-mobile-web-resume-route-memory.ts @@ -0,0 +1,33 @@ +import { useMemo, useRef } from 'react' +import type { MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract' + +export type MobileWebResumeRouteMemory = { + remember: (route: MobileWebResumeRoute) => void + current: () => MobileWebResumeRoute +} + +/** The route the shell replays in `init` when the hosted document reloads. A package swap or a + * recovery mints a new shell session on the same host, and the reloaded page belongs on the route + * the user was reading — discarding it there boots the page on the workspace list and leaves the + * slow cold-resume round trip to bring the session back. Only another host makes the remembered + * workspace id meaningless. */ +export function useMobileWebResumeRouteMemory( + hostId: string | undefined +): MobileWebResumeRouteMemory { + const rememberedRef = useRef<{ hostId: string | undefined; route: MobileWebResumeRoute }>({ + hostId, + route: { kind: 'workspaceList' } + }) + return useMemo( + () => ({ + remember: (route) => { + rememberedRef.current = { hostId, route } + }, + current: () => + rememberedRef.current.hostId === hostId + ? rememberedRef.current.route + : { kind: 'workspaceList' } + }), + [hostId] + ) +}