fix(mobile): keep a first open checking while its dial is still retrying (#36863)

The very first time a host is opened there is no cached page, so a
single failed dial (a 12 s connect deadline or a 5 s handshake on a
slow link) put up the offline screen, 'Connect to this host to download
the latest app', while the phone was still redialing and often seconds
from connecting.

The shell's gates now carry whether the dial is still within the
attempts the connection label calls connecting (the same three it
waits before warning). A first open keeps checking through them; when
they run out the gate verdict changes, the flow restarts and the
offline screen shows as before. A cached page still opens at once, as
today.

The download-failure decision moves to mobile-web-shell-download-failure.ts
unchanged, to keep the session reducer under the size cap.
This commit is contained in:
Neil
2026-10-31 21:07:52 -07:00
committed by GitHub
parent 782e867be5
commit 6df565a130
7 changed files with 99 additions and 51 deletions
@@ -0,0 +1,52 @@
import type { MobileWebShellSession, MobileWebShellStep } from './mobile-web-shell-session-contract'
import {
readFailureSide,
type MobileWebShellUpdateFailureCause,
type MobileWebShellUpdateFailureFacts
} from './mobile-web-shell-update-failure'
import { updateFailureOutcomeOf } from './mobile-web-shell-update-failure-outcome'
import { cachedGenerationWall, gateState, gateVerdict } from './mobile-web-shell-gates'
import { openByOwnRoutes } from './mobile-web-shell-cached-generation'
import { step } from './mobile-web-shell-session-step'
// Record the resulting decision once for Troubleshoot; release builds forward no console logs.
export function onDownloadFailed(
session: MobileWebShellSession,
cause: MobileWebShellUpdateFailureCause
): MobileWebShellStep {
const decided = decideDownloadFailed(session, cause)
const failure: MobileWebShellUpdateFailureFacts = {
...cause,
offeredBuildId: session.requestedBuildId,
cachedBuildId: session.cached?.buildId ?? null,
...updateFailureOutcomeOf(decided.session.state)
}
return { ...decided, effects: [...decided.effects, { kind: 'record-update-failure', failure }] }
}
// Fall back to intact cached bytes under their own routes, checking compatibility when the host remains reachable.
// Name only bundle refusals: link loss cannot establish an update existed; absent cache still gets download failure.
function decideDownloadFailed(
session: MobileWebShellSession,
cause: MobileWebShellUpdateFailureCause
): MobileWebShellStep {
const cached = session.cached
const gates = session.gates
if (cached === null || gates === null) {
return step(session, {
state: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce }
})
}
// Read current gates because they can change while fetching without restarting the flow.
const verdict = gateVerdict(gates)
const gated = gateState(verdict, session.retriedOnce)
if (gated !== null) {
return step(session, { state: gated })
}
return openByOwnRoutes(session, cached, {
// Missing cached routes remain a retryable download failure after the new manifest claimed them or never arrived.
unclaimed: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce },
served: { updateNotice: readFailureSide(cause.reason) === 'bundle' ? 'update-failed' : null },
wall: cachedGenerationWall(verdict, gates, cached.compat)
})
}
@@ -10,6 +10,7 @@ import type {
// Reuse state identities so equivalent transitions do not cause extra renders.
export const CHECKING: MobileWebShellSessionState = { kind: 'checking' }
export const OFFLINE: MobileWebShellSessionState = { kind: 'offline' }
export const ROUTE_UNAVAILABLE: MobileWebShellSessionState = { kind: 'route-unavailable' }
// Keep displayed generations stable through reconnects; only checking/offline and unreadable-status failures wait for new gates.
@@ -89,6 +90,8 @@ export function cachedGenerationWall(
}
// Compare verdicts instead of rebuilt gate objects so unchanged reconnect/status events cannot resweep cache and flicker the spinner.
// An early retry ending is a change: a first open waiting on it must go on to say offline.
export function gateKey(gates: MobileWebShellGates): string {
return gateVerdict(gates).kind
const kind = gateVerdict(gates).kind
return kind === 'offline' && gates.retrying === true ? 'offline-retrying' : kind
}
@@ -18,6 +18,8 @@ export type MobileWebShellGates = {
// Unreadable includes absent/undecodable replies; their empty capabilities must not masquerade as bundle-unavailable.
readonly statusReadable: boolean
readonly reachability: MobileWebShellReachability
// Unreachable, but still redialing within the attempts the UI calls connecting; absent reads as false.
readonly retrying?: boolean
readonly hostCapabilities: readonly string[]
readonly hostStatus: MobileWebBundleHostStatus
}
@@ -611,6 +611,30 @@ describe('a gates change that says nothing new starts nothing', () => {
expect(again.session.flow).toBe(checking.session.flow)
})
it('keeps a first open checking while the dial retries, then says offline when it stops', () => {
const retrying = gates({ reachability: 'unreachable', retrying: true })
const waiting = run(started(retrying).session, { type: 'cache-read', generation: null })
expect(waiting.session.state).toEqual({ kind: 'checking' })
const gaveUp = run(waiting.session, {
type: 'gates-changed',
gates: gates({ reachability: 'unreachable' })
})
expect(gaveUp.effects).toEqual([{ kind: 'open-cache' }])
const offline = run(gaveUp.session, { type: 'cache-read', generation: null })
expect(offline.session.state).toEqual({ kind: 'offline' })
})
it('still opens a cached page at once while the dial retries', () => {
const retrying = gates({ reachability: 'unreachable', retrying: true })
const step = run(started(retrying).session, { type: 'cache-read', generation: CACHED })
const offline = gates({ reachability: 'unreachable' })
const unretried = run(started(offline).session, { type: 'cache-read', generation: CACHED })
expect(step.session.state).toEqual(unretried.session.state)
expect(step.effects).toEqual(unretried.effects)
expect(step.session.state.kind).toBe('activating')
})
it('holds the offline screen through a reconnect cycle that never reaches the host', () => {
const offline = run(started({ reachability: 'unreachable' }).session, {
type: 'cache-read',
@@ -9,16 +9,11 @@ import type {
MobileWebShellSessionEvent,
MobileWebShellStep
} from './mobile-web-shell-session-contract'
import {
readFailureSide,
type MobileWebShellUpdateFailureCause,
type MobileWebShellUpdateFailureFacts
} from './mobile-web-shell-update-failure'
import { updateFailureOutcomeOf } from './mobile-web-shell-update-failure-outcome'
import { onDownloadFailed } from './mobile-web-shell-download-failure'
import {
awaitsGates,
cachedGenerationWall,
CHECKING,
OFFLINE,
gateKey,
gateState,
gateVerdict,
@@ -94,7 +89,8 @@ function onCacheRead(
if (gates.reachability === 'unreachable') {
// No current host verdict exists offline; open trusted cache and recheck on the next connected entry.
if (generation === null) {
return step(session, { cached: null, state: { kind: 'offline' } })
// A first open has nothing to show offline; keep checking while the dial is still retrying.
return step(session, { cached: null, ...(gates.retrying === true ? {} : { state: OFFLINE }) })
}
// Use cached routes offline; an absent route means offline, not evidence the unreachable desktop is outdated.
return openByOwnRoutes(session, generation, {
@@ -182,48 +178,6 @@ function onShellFailed(session: MobileWebShellSession, reason: ShellFailure): Mo
])
}
// Record the resulting decision once for Troubleshoot; release builds forward no console logs.
function onDownloadFailed(
session: MobileWebShellSession,
cause: MobileWebShellUpdateFailureCause
): MobileWebShellStep {
const decided = decideDownloadFailed(session, cause)
const failure: MobileWebShellUpdateFailureFacts = {
...cause,
offeredBuildId: session.requestedBuildId,
cachedBuildId: session.cached?.buildId ?? null,
...updateFailureOutcomeOf(decided.session.state)
}
return { ...decided, effects: [...decided.effects, { kind: 'record-update-failure', failure }] }
}
// Fall back to intact cached bytes under their own routes, checking compatibility when the host remains reachable.
// Name only bundle refusals: link loss cannot establish an update existed; absent cache still gets download failure.
function decideDownloadFailed(
session: MobileWebShellSession,
cause: MobileWebShellUpdateFailureCause
): MobileWebShellStep {
const cached = session.cached
const gates = session.gates
if (cached === null || gates === null) {
return step(session, {
state: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce }
})
}
// Read current gates because they can change while fetching without restarting the flow.
const verdict = gateVerdict(gates)
const gated = gateState(verdict, session.retriedOnce)
if (gated !== null) {
return step(session, { state: gated })
}
return openByOwnRoutes(session, cached, {
// Missing cached routes remain a retryable download failure after the new manifest claimed them or never arrived.
unclaimed: { kind: 'failed', reason: 'download-failed', retriedOnce: session.retriedOnce },
served: { updateNotice: readFailureSide(cause.reason) === 'bundle' ? 'update-failed' : null },
wall: cachedGenerationWall(verdict, gates, cached.compat)
})
}
function onLayoutChanged(session: MobileWebShellSession, wide: boolean): MobileWebShellStep {
if (session.state.kind === 'failed' && session.state.reason === 'host-profile-unreadable') {
return step(session, { wide })
@@ -5,6 +5,7 @@ import { provenHostStatus } from '../transport/proven-host-status'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { readMobileWebShellReachability } from './mobile-web-shell-reachability'
import { isEarlyConnectRetry } from '../transport/connection-health'
import type { MobileWebShellGates } from './mobile-web-shell-session-contract'
/** Feeds the host route's gates to the shell session, and returns the status they proved for the page's init. */
@@ -19,11 +20,14 @@ export function useMobileWebShellGates(args: {
const { gates, connState, client, rearmKey, onGates } = args
const { statusPending, statusReadable, hostCapabilities, hostProtocolWindow } = gates
const reachability = readMobileWebShellReachability(connState, client)
const retrying =
client !== null && isEarlyConnectRetry(connState, () => client.getReconnectAttempt())
useEffect(() => {
onGates({
statusPending,
statusReadable,
reachability,
retrying,
hostCapabilities,
hostStatus: hostProtocolWindow
})
@@ -34,6 +38,7 @@ export function useMobileWebShellGates(args: {
rearmKey,
hostProtocolWindow,
reachability,
retrying,
statusPending,
statusReadable
])
@@ -7,6 +7,14 @@ import type { ConnectionState } from './types'
// Absorb brief wake/network failures before warning; keep unreachable aligned with the retry loop’s GIVE_UP_AFTER_ATTEMPTS.
// After a prior success, stale time distinguishes a changed/unreachable host from a transient drop.
const WARNING_ATTEMPTS = 3
/** A redial still inside the attempts the UI calls connecting, before it warns the host is unreachable. */
export function isEarlyConnectRetry(
state: ConnectionState,
readReconnectAttempts: () => number
): boolean {
return state === 'reconnecting' && readReconnectAttempts() < WARNING_ATTEMPTS
}
const UNREACHABLE_ATTEMPTS = 12
const STALE_SINCE_LAST_CONNECT_MS = 60_000