From 02f2a9f8927dd68e86c7b04aa8da607f6693786b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 12:07:00 +0200 Subject: [PATCH 1/4] feat(mobile): check for app updates on launch - Add tested OTA update and rollback handling - Trigger manual checks after five version-row taps --- .../src/features/home/HomeRouteScreen.tsx | 6 + .../features/settings/SettingsRouteScreen.tsx | 117 ++++----------- .../src/features/updates/app-updates.test.ts | 138 ++++++++++++++++++ .../src/features/updates/app-updates.ts | 119 +++++++++++++++ 4 files changed, 289 insertions(+), 91 deletions(-) create mode 100644 apps/mobile/src/features/updates/app-updates.test.ts create mode 100644 apps/mobile/src/features/updates/app-updates.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 9fa179f4c76..79933e14c39 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -11,6 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { checkForAppUpdateOnLaunch } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; @@ -29,6 +30,11 @@ export function HomeRouteScreen() { const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); + + useEffect(() => { + void checkForAppUpdateOnLaunch(); + }, []); + const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f354bcd29ac..49adfe75cb2 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { - ActivityIndicator, - Alert, - Linking, - Platform, - Pressable, - ScrollView, - View, -} from "react-native"; +import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { - type AtomCommandResult, isAtomCommandInterrupted, reportAtomCommandResult, settleAsyncResult, @@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { + type AppUpdateCheckState, + registerHiddenUpdateTap, + runAppUpdateCheck, +} from "../updates/app-updates"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -578,12 +574,11 @@ function BetaSettingsSection() { ); } -type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; - function AppSettingsSection() { const icon = useThemeColor("--color-icon"); - const [updateState, setUpdateState] = useState("idle"); + const [updateState, setUpdateState] = useState("idle"); const updateInFlight = useRef(false); + const hiddenUpdateTapCount = useRef(0); const version = Constants.expoConfig?.version ?? "0.0.0"; // Fall back to "production" to match resolveAppVariant in app.config.ts, so a @@ -591,22 +586,11 @@ function AppSettingsSection() { const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; const variantLabel = variant === "production" ? "" : capitalize(variant); const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; - // Which JS is actually running: the bundle shipped in the binary, or an OTA - // update downloaded on top of it. Surfacing this makes "am I even on the - // right build?" answerable at a glance. - const bundleLabel = Updates.isEnabled - ? Updates.isEmbeddedLaunch - ? "Embedded" - : Updates.updateId - ? `OTA ${Updates.updateId.slice(0, 7)}` - : null - : null; - const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; // "Up to date" is a transient acknowledgement, not a state worth persisting — - // drop back to the bundle label so the row keeps answering "what am I running?". + // return the version row to its normal, deliberately quiet state. useEffect(() => { if (updateState !== "current") return; const timer = setTimeout(() => setUpdateState("idle"), 3000); @@ -619,12 +603,24 @@ function AppSettingsSection() { if (updateInFlight.current) return; updateInFlight.current = true; try { - await runUpdateCheck(setUpdateState); + await runAppUpdateCheck({ + onFailure: (message) => Alert.alert("Update failed", message), + onStateChange: setUpdateState, + }); } finally { updateInFlight.current = false; } }, []); + const handleVersionPress = useCallback(() => { + if (!Updates.isEnabled || updateInFlight.current) return; + const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); + hiddenUpdateTapCount.current = tap.nextCount; + if (tap.shouldCheck) { + void checkForUpdate(); + } + }, [checkForUpdate]); + const statusLabel = updateState === "checking" ? "Checking…" @@ -634,7 +630,7 @@ function AppSettingsSection() { ? "Restarting…" : updateState === "current" ? "Up to date" - : bundleLabel; + : null; const versionRow = ( @@ -652,21 +648,6 @@ function AppSettingsSection() { {statusLabel} ) : null} - {Updates.isEnabled ? ( - - {busy ? ( - - ) : ( - - )} - - ) : null} ); @@ -676,10 +657,10 @@ function AppSettingsSection() { {Updates.isEnabled ? ( void checkForUpdate()} + onPress={handleVersionPress} > {versionRow} @@ -690,52 +671,6 @@ function AppSettingsSection() { ); } -async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise { - setUpdateState("checking"); - const check = await settlePromise(() => Updates.checkForUpdateAsync()); - if (check._tag === "Failure") { - reportUpdateFailure(check, "Could not check for updates."); - setUpdateState("idle"); - return; - } - // A rollback directive (`eas update:rollback`) arrives as isAvailable: false - // with isRollBackToEmbedded: true — there is nothing newer to install, but the - // running OTA still has to be dropped for the embedded bundle. - if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { - setUpdateState("current"); - return; - } - - setUpdateState("downloading"); - const fetched = await settlePromise(() => Updates.fetchUpdateAsync()); - if (fetched._tag === "Failure") { - reportUpdateFailure(fetched, "Could not download the update."); - setUpdateState("idle"); - return; - } - // isNew is always false for a rollback, so it can't be the sole gate here either. - if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { - setUpdateState("current"); - return; - } - - setUpdateState("restarting"); - // reloadAsync never resolves on success — the JS context is torn down — so - // reaching the failure branch below is the only way this returns. - const reloaded = await settlePromise(() => Updates.reloadAsync()); - if (reloaded._tag === "Failure") { - reportUpdateFailure(reloaded, "Downloaded, but could not restart the app."); - setUpdateState("idle"); - } -} - -function reportUpdateFailure(result: AtomCommandResult, fallback: string): void { - reportAtomCommandResult(result, { label: "app update check" }); - if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; - const error = squashAtomCommandFailure(result); - Alert.alert("Update failed", error instanceof Error ? error.message : fallback); -} - function capitalize(value: string): string { return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; } diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts new file mode 100644 index 00000000000..f3b1361ccaa --- /dev/null +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + createAppUpdateLaunchCheck, + registerHiddenUpdateTap, + runAppUpdateCheck, + type AppUpdateCheckState, + type AppUpdateClient, +} from "./app-updates"; + +vi.mock("expo-updates", () => ({ + isEnabled: true, + checkForUpdateAsync: vi.fn(), + fetchUpdateAsync: vi.fn(), + reloadAsync: vi.fn(), +})); + +function makeUpdateClient(overrides: Partial = {}): AppUpdateClient { + return { + isEnabled: true, + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: false, + isRollBackToEmbedded: false, + })), + fetchUpdateAsync: vi.fn(async () => ({ + isNew: true, + isRollBackToEmbedded: false, + })), + reloadAsync: vi.fn(async () => {}), + ...overrides, + }; +} + +describe("runAppUpdateCheck", () => { + it("downloads and restarts when a new update is available", async () => { + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: true, + isRollBackToEmbedded: false, + })), + }); + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); + expect(client.reloadAsync).toHaveBeenCalledOnce(); + expect(states).toEqual(["checking", "downloading", "restarting"]); + }); + + it("restarts into the embedded bundle for a rollback directive", async () => { + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => ({ + isAvailable: false, + isRollBackToEmbedded: true, + })), + fetchUpdateAsync: vi.fn(async () => ({ + isNew: false, + isRollBackToEmbedded: true, + })), + }); + + await runAppUpdateCheck({ client }); + + expect(client.fetchUpdateAsync).toHaveBeenCalledOnce(); + expect(client.reloadAsync).toHaveBeenCalledOnce(); + }); + + it("stops quietly when the running bundle is current", async () => { + const client = makeUpdateClient(); + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) }); + + expect(client.fetchUpdateAsync).not.toHaveBeenCalled(); + expect(client.reloadAsync).not.toHaveBeenCalled(); + expect(states).toEqual(["checking", "current"]); + }); + + it("reports manual failures without continuing the update", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => { + throw new Error("offline"); + }), + }); + const failures: string[] = []; + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ + client, + onFailure: (message) => failures.push(message), + onStateChange: (state) => states.push(state), + }); + + expect(client.fetchUpdateAsync).not.toHaveBeenCalled(); + expect(failures).toEqual(["offline"]); + expect(states).toEqual(["checking", "idle"]); + reportError.mockRestore(); + }); +}); + +describe("createAppUpdateLaunchCheck", () => { + it("checks at most once for each JavaScript launch", async () => { + const client = makeUpdateClient(); + const checkOnLaunch = createAppUpdateLaunchCheck(client); + + const first = checkOnLaunch(); + const second = checkOnLaunch(); + await first; + + expect(second).toBeUndefined(); + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + }); + + it("does nothing when Expo updates are disabled", () => { + const client = makeUpdateClient({ isEnabled: false }); + const checkOnLaunch = createAppUpdateLaunchCheck(client); + + expect(checkOnLaunch()).toBeUndefined(); + expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); + }); +}); + +describe("registerHiddenUpdateTap", () => { + it("unlocks the manual check on the fifth tap", () => { + let count = 0; + + for (let tap = 1; tap <= 5; tap += 1) { + const result = registerHiddenUpdateTap(count); + expect(result.shouldCheck).toBe(tap === 5); + count = result.nextCount; + } + + expect(count).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts new file mode 100644 index 00000000000..39f14db8521 --- /dev/null +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -0,0 +1,119 @@ +import * as Updates from "expo-updates"; + +import { + type AtomCommandResult, + isAtomCommandInterrupted, + reportAtomCommandResult, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +export type AppUpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; + +export interface AppUpdateClient { + readonly isEnabled: boolean; + readonly checkForUpdateAsync: () => Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>; + readonly fetchUpdateAsync: () => Promise<{ + readonly isNew: boolean; + readonly isRollBackToEmbedded: boolean; + }>; + readonly reloadAsync: () => Promise; +} + +interface AppUpdateCheckOptions { + readonly client?: AppUpdateClient; + readonly onFailure?: (message: string) => void; + readonly onStateChange?: (state: AppUpdateCheckState) => void; +} + +const HIDDEN_UPDATE_TAP_COUNT = 5; + +/** + * Keeps the manual update affordance discoverable only to someone deliberately + * tapping the version row five times. + */ +export function registerHiddenUpdateTap(count: number): { + readonly nextCount: number; + readonly shouldCheck: boolean; +} { + const nextCount = count + 1; + if (nextCount >= HIDDEN_UPDATE_TAP_COUNT) { + return { + nextCount: 0, + shouldCheck: true, + }; + } + return { + nextCount, + shouldCheck: false, + }; +} + +export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise { + const client = options.client ?? Updates; + if (!client.isEnabled) return; + + const setState = options.onStateChange ?? (() => {}); + + setState("checking"); + const check = await settlePromise(() => client.checkForUpdateAsync()); + if (check._tag === "Failure") { + reportUpdateFailure(check, "Could not check for updates.", options.onFailure); + setState("idle"); + return; + } + // A rollback directive (`eas update:rollback`) arrives as isAvailable: false + // with isRollBackToEmbedded: true. The running OTA still has to be dropped. + if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { + setState("current"); + return; + } + + setState("downloading"); + const fetched = await settlePromise(() => client.fetchUpdateAsync()); + if (fetched._tag === "Failure") { + reportUpdateFailure(fetched, "Could not download the update.", options.onFailure); + setState("idle"); + return; + } + // isNew is always false for a rollback, so it cannot be the sole gate. + if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { + setState("current"); + return; + } + + setState("restarting"); + const reloaded = await settlePromise(() => client.reloadAsync()); + if (reloaded._tag === "Failure") { + reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure); + setState("idle"); + } +} + +function reportUpdateFailure( + result: AtomCommandResult, + fallback: string, + onFailure: AppUpdateCheckOptions["onFailure"], +): void { + reportAtomCommandResult(result, { label: "app update check" }); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + onFailure?.(error instanceof Error ? error.message : fallback); +} + +export function createAppUpdateLaunchCheck( + client: AppUpdateClient = Updates, +): () => Promise | undefined { + let started = false; + + return () => { + if (started || !client.isEnabled) return undefined; + started = true; + return runAppUpdateCheck({ client }); + }; +} + +export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); From 9166f7dae1eba3b57c323ca66ce6c1eabaebccbd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 15:00:08 +0200 Subject: [PATCH 2/4] fix(mobile): coalesce app update checks Prevent launch and manual update checks from running the Expo update flow concurrently, and cover the shared guard with a regression test. Co-authored-by: codex --- .../src/features/updates/app-updates.test.ts | 31 +++++++++++++++++++ .../src/features/updates/app-updates.ts | 21 +++++++++++++ 2 files changed, 52 insertions(+) diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index f3b1361ccaa..e1550991418 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -99,6 +99,37 @@ describe("runAppUpdateCheck", () => { expect(states).toEqual(["checking", "idle"]); reportError.mockRestore(); }); + + it("coalesces overlapping launch and manual checks", async () => { + let resolveCheck!: (result: { + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }) => void; + const checkResult = new Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>((resolve) => { + resolveCheck = resolve; + }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(() => checkResult), + }); + const checkOnLaunch = createAppUpdateLaunchCheck(client); + + const launchCheck = checkOnLaunch(); + const manualCheck = runAppUpdateCheck({ client }); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + + resolveCheck({ + isAvailable: false, + isRollBackToEmbedded: false, + }); + await Promise.all([launchCheck, manualCheck]); + + await runAppUpdateCheck({ client }); + expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2); + }); }); describe("createAppUpdateLaunchCheck", () => { diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 39f14db8521..488b8e542b3 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -30,6 +30,7 @@ interface AppUpdateCheckOptions { } const HIDDEN_UPDATE_TAP_COUNT = 5; +let appUpdateCheckInFlight: Promise | undefined; /** * Keeps the manual update affordance discoverable only to someone deliberately @@ -56,6 +57,26 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr const client = options.client ?? Updates; if (!client.isEnabled) return; + if (appUpdateCheckInFlight) { + await appUpdateCheckInFlight; + return; + } + + const check = performAppUpdateCheck(client, options); + appUpdateCheckInFlight = check; + try { + await check; + } finally { + if (appUpdateCheckInFlight === check) { + appUpdateCheckInFlight = undefined; + } + } +} + +async function performAppUpdateCheck( + client: AppUpdateClient, + options: AppUpdateCheckOptions, +): Promise { const setState = options.onStateChange ?? (() => {}); setState("checking"); From 7961ebbbfafabd865fb8e6c535ffb8addb53ed91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 15:38:58 +0200 Subject: [PATCH 3/4] fix(mobile): forward coalesced app update check progress - Notify coalesced manual checks of state changes and failures - Add coverage for launch/manual check overlap --- .../src/features/updates/app-updates.test.ts | 41 ++++++++++- .../src/features/updates/app-updates.ts | 72 +++++++++++++++++-- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index e1550991418..571bc365703 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -115,11 +115,16 @@ describe("runAppUpdateCheck", () => { checkForUpdateAsync: vi.fn(() => checkResult), }); const checkOnLaunch = createAppUpdateLaunchCheck(client); + const manualStates: AppUpdateCheckState[] = []; const launchCheck = checkOnLaunch(); - const manualCheck = runAppUpdateCheck({ client }); + const manualCheck = runAppUpdateCheck({ + client, + onStateChange: (state) => manualStates.push(state), + }); expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(manualStates).toEqual(["checking"]); resolveCheck({ isAvailable: false, @@ -127,9 +132,43 @@ describe("runAppUpdateCheck", () => { }); await Promise.all([launchCheck, manualCheck]); + expect(manualStates).toEqual(["checking", "current"]); + await runAppUpdateCheck({ client }); expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2); }); + + it("forwards failures to a manual check coalesced with the launch check", async () => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + let rejectCheck!: (error: Error) => void; + const checkResult = new Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>((_resolve, reject) => { + rejectCheck = reject; + }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(() => checkResult), + }); + const checkOnLaunch = createAppUpdateLaunchCheck(client); + const failures: string[] = []; + const manualStates: AppUpdateCheckState[] = []; + + const launchCheck = checkOnLaunch(); + const manualCheck = runAppUpdateCheck({ + client, + onFailure: (message) => failures.push(message), + onStateChange: (state) => manualStates.push(state), + }); + + rejectCheck(new Error("offline")); + await Promise.all([launchCheck, manualCheck]); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(failures).toEqual(["offline"]); + expect(manualStates).toEqual(["checking", "idle"]); + reportError.mockRestore(); + }); }); describe("createAppUpdateLaunchCheck", () => { diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 488b8e542b3..3e4c8ee703a 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -29,8 +29,20 @@ interface AppUpdateCheckOptions { readonly onStateChange?: (state: AppUpdateCheckState) => void; } +interface AppUpdateCheckProgress { + failure: string | undefined; + state: AppUpdateCheckState | undefined; +} + +interface AppUpdateCheckInFlight { + readonly failureListeners: Set>; + readonly progress: AppUpdateCheckProgress; + readonly promise: Promise; + readonly stateListeners: Set>; +} + const HIDDEN_UPDATE_TAP_COUNT = 5; -let appUpdateCheckInFlight: Promise | undefined; +let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; /** * Keeps the manual update affordance discoverable only to someone deliberately @@ -58,21 +70,69 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr if (!client.isEnabled) return; if (appUpdateCheckInFlight) { - await appUpdateCheckInFlight; + await observeAppUpdateCheck(appUpdateCheckInFlight, options); return; } - const check = performAppUpdateCheck(client, options); - appUpdateCheckInFlight = check; + const progress: AppUpdateCheckProgress = { + failure: undefined, + state: undefined, + }; + const failureListeners = new Set>(); + const stateListeners = new Set>(); + if (options.onFailure) failureListeners.add(options.onFailure); + if (options.onStateChange) stateListeners.add(options.onStateChange); + + const promise = performAppUpdateCheck(client, { + onFailure: (message) => { + progress.failure = message; + for (const listener of failureListeners) listener(message); + }, + onStateChange: (state) => { + progress.state = state; + for (const listener of stateListeners) listener(state); + }, + }); + const inFlight: AppUpdateCheckInFlight = { + failureListeners, + progress, + promise, + stateListeners, + }; + appUpdateCheckInFlight = inFlight; try { - await check; + await promise; } finally { - if (appUpdateCheckInFlight === check) { + if (appUpdateCheckInFlight === inFlight) { appUpdateCheckInFlight = undefined; } } } +async function observeAppUpdateCheck( + inFlight: AppUpdateCheckInFlight, + options: AppUpdateCheckOptions, +): Promise { + const onFailure = options.onFailure; + const onStateChange = options.onStateChange; + + if (onFailure) { + inFlight.failureListeners.add(onFailure); + if (inFlight.progress.failure) onFailure(inFlight.progress.failure); + } + if (onStateChange) { + inFlight.stateListeners.add(onStateChange); + if (inFlight.progress.state) onStateChange(inFlight.progress.state); + } + + try { + await inFlight.promise; + } finally { + if (onFailure) inFlight.failureListeners.delete(onFailure); + if (onStateChange) inFlight.stateListeners.delete(onStateChange); + } +} + async function performAppUpdateCheck( client: AppUpdateClient, options: AppUpdateCheckOptions, From 9bdfdcbc15aa5991b7f3842f38a6f461090f26fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Jul 2026 15:44:45 +0200 Subject: [PATCH 4/4] fix(mobile): publish update checks before notifying listeners - Prevent reentrant checks from starting duplicate update requests - Add regression coverage for synchronous state callback re-entry --- .../src/features/updates/app-updates.test.ts | 44 ++++++++++++++++ .../src/features/updates/app-updates.ts | 50 +++++++++++++++---- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index 571bc365703..474c99668cd 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -169,6 +169,50 @@ describe("runAppUpdateCheck", () => { expect(manualStates).toEqual(["checking", "idle"]); reportError.mockRestore(); }); + + it("publishes the in-flight check before a state callback can re-enter", async () => { + let resolveCheck!: (result: { + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }) => void; + const checkResult = new Promise<{ + readonly isAvailable: boolean; + readonly isRollBackToEmbedded: boolean; + }>((resolve) => { + resolveCheck = resolve; + }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(() => checkResult), + }); + const reentrantStates: AppUpdateCheckState[] = []; + let reentrantCheck: Promise | undefined; + let didReenter = false; + + const initialCheck = runAppUpdateCheck({ + client, + onStateChange: (state) => { + if (state !== "checking" || didReenter) return; + didReenter = true; + reentrantCheck = runAppUpdateCheck({ + client, + onStateChange: (reentrantState) => reentrantStates.push(reentrantState), + }); + }, + }); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(reentrantCheck).toBeDefined(); + expect(reentrantStates).toEqual(["checking"]); + + resolveCheck({ + isAvailable: false, + isRollBackToEmbedded: false, + }); + await Promise.all([initialCheck, reentrantCheck]); + + expect(client.checkForUpdateAsync).toHaveBeenCalledOnce(); + expect(reentrantStates).toEqual(["checking", "current"]); + }); }); describe("createAppUpdateLaunchCheck", () => { diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index 3e4c8ee703a..ab896b53c07 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -41,6 +41,12 @@ interface AppUpdateCheckInFlight { readonly stateListeners: Set>; } +interface Deferred { + readonly promise: Promise; + readonly reject: (cause: unknown) => void; + readonly resolve: () => void; +} + const HIDDEN_UPDATE_TAP_COUNT = 5; let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; @@ -83,25 +89,30 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr if (options.onFailure) failureListeners.add(options.onFailure); if (options.onStateChange) stateListeners.add(options.onStateChange); - const promise = performAppUpdateCheck(client, { + const deferred = createDeferred(); + const inFlight: AppUpdateCheckInFlight = { + failureListeners, + progress, + promise: deferred.promise, + stateListeners, + }; + // Publish the operation before any state listener can synchronously re-enter. + appUpdateCheckInFlight = inFlight; + + const execution = performAppUpdateCheck(client, { onFailure: (message) => { progress.failure = message; - for (const listener of failureListeners) listener(message); + notifyListeners(failureListeners, message); }, onStateChange: (state) => { progress.state = state; - for (const listener of stateListeners) listener(state); + notifyListeners(stateListeners, state); }, }); - const inFlight: AppUpdateCheckInFlight = { - failureListeners, - progress, - promise, - stateListeners, - }; - appUpdateCheckInFlight = inFlight; + void execution.then(deferred.resolve, deferred.reject); + try { - await promise; + await deferred.promise; } finally { if (appUpdateCheckInFlight === inFlight) { appUpdateCheckInFlight = undefined; @@ -109,6 +120,23 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr } } +function createDeferred(): Deferred { + let reject!: Deferred["reject"]; + let resolve!: Deferred["resolve"]; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = () => resolvePromise(); + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function notifyListeners(listeners: ReadonlySet<(value: A) => void>, value: A): void { + // A listener can synchronously subscribe another caller. Snapshot so that + // caller receives only observeAppUpdateCheck's explicit current-value replay. + const snapshot = Array.from(listeners); + for (const listener of snapshot) listener(value); +} + async function observeAppUpdateCheck( inFlight: AppUpdateCheckInFlight, options: AppUpdateCheckOptions,