fix(mobile): recover host profile reads without discarding cached bundles (#34806)

* fix(mobile): retry unavailable host profiles without discarding cache

* fix(mobile): cancel startup work on terminal shell failures via flow advance

Merging main pushed use-mobile-web-shell-session.ts over max-lines; move
the host-profile cancellation into the reducer so the flow-change cancel
path from #34780 handles it.

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 12:48:17 -07:00
committed by GitHub
co-authored by OrcaWin
parent e0420b3b58
commit 7d29cd0839
9 changed files with 314 additions and 21 deletions
@@ -297,7 +297,7 @@ describe('the hybrid shell screen', () => {
const logged = () => connectionLogStore.get('host-1').map((entry) => entry.message)
const before = logged().length
await renderScreen(readyState('session-one'))
expect(dependencies.reportShellFailure.mock.calls).toEqual([['document-load-failed']])
expect(dependencies.reportShellFailure.mock.calls).toEqual([['host-profile-unreadable']])
expect(logged().slice(before)).toEqual(['Web shell: host profile unreadable'])
warned.mockRestore()
})
@@ -111,8 +111,15 @@ export function MobileWebShellScreen({
// Edge-to-edge disables adjustResize and zeroes WebView IME insets, so visualViewport never shrinks; publish native keyboard height to the page.
const keyboardInset = Math.max(0, useKeyboardOcclusion())
const pageInsets = pageSafeAreaInsets({ insets, topCovered: noticeShown })
const { snapshot, unreadable, readStorage, refreshStorage, writeStorage, readWorkspaceKey } =
usePageHostSnapshot(hostId, route.pathname, ownsHostArea)
const {
snapshot,
unreadable,
retry: retryHostSnapshot,
readStorage,
refreshStorage,
writeStorage,
readWorkspaceKey
} = usePageHostSnapshot(hostId, route.pathname, ownsHostArea)
// Create the media registry before the bridge; its staged files must belong to this page session.
const serveDeviceVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null)
const servePdfView = useNativePdfViewer(hostId, state.kind === 'ready' ? state.sessionId : null)
@@ -232,11 +239,11 @@ export function MobileWebShellScreen({
})
useReportedShellOverlayLoad(onOverlayLoad, state, pageFrame, downloadProgress)
// A rejected profile read creates no host; fail the document instead of leaving ready forever. Recovery refetches once, then exposes Try again.
// Profile failures preserve verified bundle bytes and retry the device store.
useEffect(() => {
if (unreadable) {
console.warn('[web-shell] this host could not be read from the app store')
reportShellFailure('document-load-failed')
reportShellFailure('host-profile-unreadable')
}
}, [reportShellFailure, unreadable])
useEffect(() => {
@@ -253,7 +260,12 @@ export function MobileWebShellScreen({
<ShellStateScreen
hostId={hostId}
state={state}
onRetry={retry}
onRetry={() => {
if (unreadable) {
retryHostSnapshot()
}
retry()
}}
downloadProgress={downloadProgress}
host={snapshot?.host ?? null}
/>
@@ -21,6 +21,8 @@ function failureMessage(reason: MobileWebShellFailureCause): string {
return "This device's WebView is too old to open the workspace safely."
case 'download-failed':
return 'The latest app could not be downloaded from this host.'
case 'host-profile-unreadable':
return 'Could not read this host on your device. Try again.'
case 'status-unreadable':
return "Could not read this host's status. Go back and reopen it."
case 'render-process-gone':
@@ -0,0 +1,272 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const harness = await vi.hoisted(async () => await import('./mobile-web-shell-screen-test-harness'))
const dependencies = vi.hoisted(() => harness.createScreenDependencies())
const { screenModuleMocks } = await vi.hoisted(
async () => await import('./mobile-web-shell-screen-test-mocks')
)
const mocks = vi.hoisted(() => screenModuleMocks(dependencies))
vi.mock('react-native', () => ({
...mocks['react-native'](),
useWindowDimensions: () => ({ width: profile.wide ? 1200 : 390, height: 844 })
}))
vi.mock('expo-clipboard', mocks['expo-clipboard'])
vi.mock('expo-haptics', mocks['expo-haptics'])
vi.mock('expo-document-picker', mocks['expo-document-picker'])
vi.mock('@orca/expo-two-way-audio', mocks['@orca/expo-two-way-audio'])
vi.mock('expo-keep-awake', mocks['expo-keep-awake'])
vi.mock('expo-image-picker', mocks['expo-image-picker'])
vi.mock('expo-file-system', mocks['expo-file-system'])
vi.mock('lucide-react-native', mocks['lucide-react-native'])
vi.mock('react-native-safe-area-context', mocks['react-native-safe-area-context'])
vi.mock('expo-router', mocks['expo-router'])
vi.mock('../../modules/orca-mobile-web-shell/src', mocks['../../modules/orca-mobile-web-shell/src'])
vi.mock('../app-update/use-wall-app-update', mocks['../app-update/use-wall-app-update'])
vi.mock('../transport/host-client-hooks', () => ({
useHostClient: () => ({ client: dependencies.client, state: 'connected' })
}))
const profile = vi.hoisted(() => ({
fails: true,
reads: 0,
downloads: 0,
wide: false,
hold: false,
canOwnHostArea: false,
release: (): void => {}
}))
vi.mock('../transport/host-store', () => ({
loadHosts: async () => {
profile.reads += 1
if (profile.hold) {
await new Promise<void>((resolve) => {
profile.release = resolve
})
}
if (profile.fails) {
throw new Error('the keychain is temporarily unavailable')
}
return [{ id: 'host-1', name: 'Host One', endpoint: 'ws://host-1', lastConnected: 3 }]
}
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
multiGet: async () => [],
getItem: async () => null,
setItem: async () => undefined,
removeItem: async () => undefined
}
}))
vi.mock('../components/host-protocol-gates-context', () => ({
useHostProtocolGates: () => ({
statusPending: false,
statusReadable: true,
hostCapabilities: [MOBILE_WEB_BUNDLE_CAPABILITY],
hostProtocolWindow: { protocolVersion: 10, minCompatibleMobileVersion: 1 }
})
}))
vi.mock('../transport/rpc-operation', async (importOriginal) => ({
...(await importOriginal<typeof RpcOperationModule>()),
runRpcOperation: async () => ({ manifest: currentManifest() })
}))
vi.mock('../transport/mobile-web-bundle-fetch', () => ({
fetchMobileWebBundle: async () => {
profile.downloads += 1
return {
manifest: currentManifest(),
assets: new Map(),
reused: [],
totalBytes: 4096,
elapsedMs: 0
}
}
}))
import type * as RpcOperationModule from '../transport/rpc-operation'
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { MOBILE_WEB_BUNDLE_CAPABILITY } from '../../../src/shared/mobile-web-bundle/mobile-web-bundle-capability'
import { MobileWebShellScreen } from './MobileWebShellScreen'
import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes'
import { byName, trackRenderedScreen, textOf } from './mobile-web-shell-screen-test-harness'
import { MANIFEST_WIRE } from './mobile-web-shell-session-test-fixtures'
import { readBridgeHostMessage } from './bridge/bridge-envelope'
import type { ActiveGeneration, GenerationStore } from './generation-store'
afterEach(harness.unmountRenderedScreens)
beforeEach(() => {
harness.resetScreenDependencies(dependencies)
profile.fails = true
profile.reads = 0
profile.downloads = 0
profile.wide = false
profile.hold = false
profile.canOwnHostArea = false
})
function currentManifest() {
return profile.canOwnHostArea
? {
...MANIFEST_WIRE,
routes: [{ pathname: '/h/[hostId]', grants: ['navigate'], canOwnHostArea: true }]
}
: MANIFEST_WIRE
}
function cachedStore() {
const active = {
buildId: MANIFEST_WIRE.buildId,
directory: 'file:///cache/generation',
manifest: currentManifest()
}
let cached: ActiveGeneration | null = active
const heldReads: (() => void)[] = []
const cache = {
holdReads: false,
releaseReads: () => {
for (const release of heldReads.splice(0)) {
release()
}
},
deletes: vi.fn(async () => {
cached = null
})
}
const store: GenerationStore = {
readActiveGeneration: async () => {
const answer = cached
if (cache.holdReads) {
await new Promise<void>((resolve) => heldReads.push(resolve))
}
return answer
},
openReusableAssets: async () => ({ has: () => false, check: async () => false }),
stageGeneration: async () => ({ ...active, hostKey: 'host-key' }),
commitGeneration: async () => {
cached = active
return active
},
abortStagedGeneration: async () => undefined,
sweepStagedGenerations: async () => undefined,
deleteHostCache: cache.deletes,
recordUpdateFailure: async () => undefined,
readUpdateFailures: async () => [],
forgetHostUpdateFailures: async () => undefined
}
return Object.assign(cache, { store })
}
async function mount(cache: ReturnType<typeof cachedStore>) {
dependencies.client = createFakeRpcClient()
const timers: { run: () => void; cancelled: boolean }[] = []
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
await act(async () => {
rendered.tree = create(
createElement(MobileWebShellScreen, {
hostId: 'host-1',
route: { pathname: '/h/host-1' },
runtime: {
createStore: () => cache.store,
mintSessionId: () => 'session-one',
now: () => 0,
setTimer: (run) => {
const timer = { run, cancelled: false }
timers.push(timer)
return () => {
timer.cancelled = true
}
}
}
})
)
trackRenderedScreen(rendered.tree)
})
const tree = rendered.tree
if (tree === null) {
throw new Error('the screen did not mount')
}
return { tree, timers }
}
async function retryWithReadableProfile(tree: ReactTestRenderer): Promise<void> {
profile.fails = false
const retry = tree.root.find(
(node) => node.props.testID === 'mobile-web-shell-retry' && String(node.type) === 'Pressable'
)
await act(async () => {
retry.props.onPress()
})
expect(profile.reads).toBe(2)
await act(async () => {
byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
nativeEvent: { json: clientFrame({ type: 'ready' }) }
})
})
const inits = dependencies.posted.flatMap((json) => {
const frame = readBridgeHostMessage(json)
return frame.ok && frame.message.type === 'init' ? [frame.message] : []
})
expect(inits).toHaveLength(1)
expect(inits[0]?.host?.id).toBe('host-1')
expect(inits[0]?.ownsHostArea).toBeUndefined()
}
it('retries an unavailable device profile without deleting or downloading verified bytes', async () => {
const cache = cachedStore()
const { tree, timers } = await mount(cache)
expect(cache.deletes).not.toHaveBeenCalled()
expect(profile.downloads).toBe(0)
expect(textOf(tree)).toContain('Could not read this host on your device')
expect(byName(tree, 'ShellViewProbe')).toHaveLength(0)
expect(timers.every((timer) => timer.cancelled)).toBe(true)
await retryWithReadableProfile(tree)
expect(cache.deletes).not.toHaveBeenCalled()
expect(profile.downloads).toBe(0)
})
it('ends checking on profile failure and ignores cache work completed after that failure', async () => {
const cache = cachedStore()
cache.holdReads = true
const { tree } = await mount(cache)
expect(textOf(tree)).toContain('Could not read this host on your device')
await act(async () => {
cache.releaseReads()
})
expect(textOf(tree)).toContain('Could not read this host on your device')
expect(byName(tree, 'ShellViewProbe')).toHaveLength(0)
cache.holdReads = false
await retryWithReadableProfile(tree)
expect(profile.downloads).toBe(0)
})
it('keeps profile failure parked across layout changes and retries with the latest layout', async () => {
profile.wide = true
profile.hold = true
profile.canOwnHostArea = true
const cache = cachedStore()
const { tree } = await mount(cache)
expect(byName(tree, 'ShellViewProbe')).toHaveLength(1)
await act(async () => {
profile.release()
})
expect(textOf(tree)).toContain('Could not read this host on your device')
for (const wide of [false, true, false]) {
profile.wide = wide
await act(async () => {
tree.update(
createElement(MobileWebShellScreen, {
hostId: 'host-1',
route: { pathname: '/h/host-1' }
})
)
})
expect(textOf(tree)).toContain('Could not read this host on your device')
expect(byName(tree, 'ShellViewProbe')).toHaveLength(0)
expect(profile.reads).toBe(1)
}
profile.hold = false
await retryWithReadableProfile(tree)
expect(cache.deletes).not.toHaveBeenCalled()
expect(profile.downloads).toBe(0)
})
@@ -169,6 +169,7 @@ export function screenModuleMocks(dependencies: ScreenDependencies) {
// Keep preference identity stable like useState; fresh objects would rebuild the bridge on every render and settle pending requests.
snapshot: SCREEN_SNAPSHOT,
unreadable: dependencies.snapshotUnreadable,
retry: () => {},
readStorage: () => ({ storage: {}, storageOversize: [] }),
refreshStorage: () => {
dependencies.storageRefreshes += 1
@@ -43,8 +43,10 @@ export type MobileWebShellBlockedVerdict =
| Extract<MobileWebBundleCompatVerdict, { kind: 'blocked' }>
| PageRouteRefusalVerdict
export type MobileWebShellReportedFailure = MobileWebShellFailureReason | 'host-profile-unreadable'
export type MobileWebShellFailureCause =
| MobileWebShellFailureReason
| MobileWebShellReportedFailure
| 'download-failed'
| 'status-unreadable'
@@ -138,7 +140,7 @@ export type MobileWebShellSessionEvent =
readonly flow: number
readonly cause: MobileWebShellUpdateFailureCause
}
| { readonly type: 'shell-failed'; readonly reason: MobileWebShellFailureReason }
| { readonly type: 'shell-failed'; readonly reason: MobileWebShellReportedFailure }
| { readonly type: 'retry-pressed' }
| { readonly type: 'document-started' }
| { readonly type: 'document-loaded' }
@@ -1,9 +1,9 @@
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
import { evaluateMobileWebBundleCompat } from '../transport/mobile-web-bundle-compat'
import type {
CachedGeneration,
MobileWebShellGates,
MobileWebShellManifestFacts,
MobileWebShellReportedFailure as ShellFailure,
MobileWebShellSession,
MobileWebShellSessionEffect,
MobileWebShellSessionEvent,
@@ -158,17 +158,15 @@ function onManifestRead(
}
// Retry suspect disk bytes once via delete/refetch; renderer loss gets one remount without deletion; unavailable isolation is terminal.
// Ignore reports outside ready: retired-view batches must not interrupt recovery already in flight.
function onShellFailed(
session: MobileWebShellSession,
reason: MobileWebShellFailureReason
): MobileWebShellStep {
if (session.state.kind !== 'ready') {
// Retired document reports cannot interrupt recovery; profile failures can end startup before ready.
function onShellFailed(session: MobileWebShellSession, reason: ShellFailure): MobileWebShellStep {
if (session.state.kind !== 'ready' && reason !== 'host-profile-unreadable') {
return step(session, {})
}
const failed = { kind: 'failed', reason, retriedOnce: session.retriedOnce } as const
if (reason === 'isolation-unavailable') {
return step(session, { state: failed })
if (reason === 'isolation-unavailable' || reason === 'host-profile-unreadable') {
// Terminal failures advance flow so pending startup work is cancelled and its completions rejected.
return step(session, { state: failed, flow: session.flow + 1 })
}
if (reason === 'render-process-gone') {
return session.remountedOnce
@@ -227,6 +225,9 @@ function decideDownloadFailed(
}
function onLayoutChanged(session: MobileWebShellSession, wide: boolean): MobileWebShellStep {
if (session.state.kind === 'failed' && session.state.reason === 'host-profile-unreadable') {
return step(session, { wide })
}
const view = routeViewOf(session.routes, session.routePathname, wide)
if (session.gates === null || sameRouteView(view, session)) {
return step(session, { wide })
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
import { useHostProtocolGates } from '../components/host-protocol-gates-context'
import { useHostClient } from '../transport/host-client-hooks'
import {
@@ -32,6 +31,7 @@ import {
reduceMobileWebShellSession
} from './mobile-web-shell-session'
import type {
MobileWebShellReportedFailure,
MobileWebShellSessionEffect,
MobileWebShellSessionEvent,
MobileWebShellSessionState,
@@ -49,7 +49,7 @@ export type MobileWebShellSessionView = {
// Explain fallback beside the running page without replacing it.
readonly updateNotice: MobileWebShellUpdateNotice | null
readonly retry: () => void
readonly reportShellFailure: (reason: MobileWebShellFailureReason) => void
readonly reportShellFailure: (reason: MobileWebShellReportedFailure) => void
readonly reportDocumentStarted: () => void
readonly reportDocumentLoaded: () => void
readonly reportPageReady: () => void
@@ -281,7 +281,7 @@ export function useMobileWebShellSession(args: {
}, [dispatch, invalidate, runtime])
const reportShellFailure = useCallback(
(reason: MobileWebShellFailureReason) => {
(reason: MobileWebShellReportedFailure) => {
dispatch(epochRef.current, { type: 'shell-failed', reason })
},
[dispatch]
@@ -23,6 +23,7 @@ export type PageHostSnapshotView = {
snapshot: PageHostSnapshot | null
// Report rejected profile reads; no host can answer ready and nothing retries this read automatically.
unreadable: boolean
retry: () => void
// Keep init synchronous from a mirror updated by native writers and refreshed from the store.
readStorage: () => PageStorageForInit
refreshStorage: () => Promise<void>
@@ -38,6 +39,7 @@ export function usePageHostSnapshot(
): PageHostSnapshotView {
const [snapshot, setSnapshot] = useState<PageHostSnapshot | null>(null)
const [unreadable, setUnreadable] = useState(false)
const [readAttempt, setReadAttempt] = useState(0)
// Apply the shell allowlist on every read/refresh; unrelated mirrored app state must never cross.
const refreshStorage = useCallback(
@@ -86,7 +88,7 @@ export function usePageHostSnapshot(
return () => {
stale = true
}
}, [hostId, refreshStorage])
}, [hostId, readAttempt, refreshStorage])
const writeStorage = useCallback(
(key: string, value: string | null): void => {
@@ -113,6 +115,7 @@ export function usePageHostSnapshot(
return {
snapshot,
unreadable,
retry: useCallback(() => setReadAttempt((attempt) => attempt + 1), []),
readStorage: useCallback(() => {
// Bound values here or one oversized preference rejects all of init; report omissions so defaulted preferences are diagnosable.
const held = readMirroredStorage(pageStorageKeysForRoute(hostId, routePathname))