From 6df565a1303299f8f2fb7a10fa2d0f3cfdab681c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:07:52 -0700 Subject: [PATCH] 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. --- .../mobile-web-shell-download-failure.ts | 52 ++++++++++++++++++ .../mobile-web-shell-gates.ts | 5 +- .../mobile-web-shell-session-contract.ts | 2 + .../mobile-web-shell-session.test.ts | 24 +++++++++ .../mobile-web-shell-session.ts | 54 ++----------------- .../use-mobile-web-shell-gates.ts | 5 ++ mobile/src/transport/connection-health.ts | 8 +++ 7 files changed, 99 insertions(+), 51 deletions(-) create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-download-failure.ts diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-download-failure.ts b/mobile/src/mobile-web-shell/mobile-web-shell-download-failure.ts new file mode 100644 index 00000000000..eb8c3037b28 --- /dev/null +++ b/mobile/src/mobile-web-shell/mobile-web-shell-download-failure.ts @@ -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) + }) +} diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts b/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts index f107c78fc7f..0c2b2281a05 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-gates.ts @@ -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 } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts index 2db075c90e1..ca41f4d9d96 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts @@ -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 } diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts index 3fa18bb385c..245949afc95 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts @@ -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', diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts index 5295afd8a88..b9f3b43b47c 100644 --- a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts +++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts @@ -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 }) diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-gates.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-gates.ts index fd3856ccd25..5811ea36fc6 100644 --- a/mobile/src/mobile-web-shell/use-mobile-web-shell-gates.ts +++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-gates.ts @@ -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 ]) diff --git a/mobile/src/transport/connection-health.ts b/mobile/src/transport/connection-health.ts index 3b7d653726f..513c004bdca 100644 --- a/mobile/src/transport/connection-health.ts +++ b/mobile/src/transport/connection-health.ts @@ -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