fix(mobile): keep the resume route across a hosted package swap

A desktop update swaps the hosted page under the user; the shell reset the
remembered route to the workspace list on every session id, so a session
only came back through cold resume after the list mounted and fetched.
Scope the memory to the host so init replays the session route directly.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-03 21:35:33 -04:00
parent fc1e78f862
commit 5bc7e95040
4 changed files with 149 additions and 16 deletions
+27 -14
View File
@@ -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<MobileWebShellViewRef>(null)
const activeSessionIdRef = useRef<string | undefined>(undefined)
const initializedSessionRef = useRef<string | undefined>(undefined)
const resumeRouteRef = useRef<MobileWebResumeRoute>({ kind: 'workspaceList' })
const healthDeadlineRef = useRef(new MobileWebHealthDeadline(10_000))
const brokerRef = useRef<MobileWebCapabilityBroker | null>(null)
const postInitRef = useRef<() => Promise<void>>(() => Promise.resolve())
@@ -64,6 +64,7 @@ export default function HybridScreen() {
const [brokerSessionId, setBrokerSessionId] = useState<string>()
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,
@@ -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/
@@ -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' })
})
})
@@ -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]
)
}