authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-14 10:46:33+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 10:46:33+02:00
log21a3669cee0c6fb54f5afe065cf0eb0348bd232c
tree441a6f11119ba383ba533ea1477ce8f66f09234d
parentb3b4b57794476c2e130d38f9243f3c5da1a0cbe7
signature Signed by PGP key B5690EEEBB952194

fix(mobile): prevent OTA update restart crashes (#6324)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

13 files changed, 959 insertions(+), 34 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+2-1
......@@ -11,7 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
1111import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1212import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
1313import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
14import { checkForAppUpdateOnLaunch } from "../updates/app-updates";
14import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates";
1515import { AndroidHomeFabLayout } from "./AndroidHomeFab";
1616import { HomeScreen } from "./HomeScreen";
1717import { HomeHeader } from "./HomeHeader";
......@@ -34,6 +34,7 @@ export function HomeRouteScreen() {
3434
3535 useEffect(() => {
3636 void checkForAppUpdateOnLaunch();
37 startAppUpdateForegroundRecheck();
3738 }, []);
3839
3940 const {
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+12-5
......@@ -608,7 +608,10 @@ function AppSettingsSection() {
608608 if (updateInFlight.current) return;
609609 updateInFlight.current = true;
610610 try {
611 // The user asked for this restart by tapping the version row, so it may
612 // apply immediately instead of prompting.
611613 await runAppUpdateCheck({
614 applyMode: "immediate",
612615 onFailure: (message) => Alert.alert("Update failed", message),
613616 onStateChange: setUpdateState,
614617 });
......@@ -631,11 +634,15 @@ function AppSettingsSection() {
631634 ? "Checking…"
632635 : updateState === "downloading"
633636 ? "Downloading…"
634 : updateState === "restarting"
635 ? "Restarting…"
636 : updateState === "current"
637 ? "Up to date"
638 : null;
637 : // "ready" appears only when this check joined an in-flight background-mode
638 // check; that download installs at the next backgrounding.
639 updateState === "ready"
640 ? "Update ready"
641 : updateState === "restarting"
642 ? "Restarting…"
643 : updateState === "current"
644 ? "Up to date"
645 : null;
639646
640647 const versionRow = (
641648 <View className="flex-row items-center gap-4 p-4">
apps/mobile/src/features/updates/app-updates.test.ts+371-9
......@@ -1,11 +1,15 @@
11import { describe, expect, it, vi } from "vite-plus/test";
22
33import {
4 createAppUpdateDeferral,
45 createAppUpdateLaunchCheck,
6 FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
57 registerHiddenUpdateTap,
68 runAppUpdateCheck,
9 shouldRecheckAppUpdateOnForeground,
710 type AppUpdateCheckState,
811 type AppUpdateClient,
12 type AppUpdateEnvironment,
913} from "./app-updates";
1014
1115vi.mock("expo-updates", () => ({
......@@ -31,6 +35,41 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl
3135 };
3236}
3337
38function makeUpdateEnvironment(overrides: Partial<AppUpdateEnvironment> = {}): {
39 readonly backgroundCallbacks: Array<() => void>;
40 readonly environment: AppUpdateEnvironment;
41 readonly foregroundStayCallbacks: Array<() => void>;
42} {
43 const backgroundCallbacks: Array<() => void> = [];
44 const foregroundStayCallbacks: Array<() => void> = [];
45 return {
46 backgroundCallbacks,
47 foregroundStayCallbacks,
48 environment: {
49 confirmInstallNow: vi.fn(async () => true),
50 flushPendingWrites: vi.fn(async () => {}),
51 isSafeToRestartInBackground: vi.fn(async () => true),
52 onNextBackground: vi.fn((apply: () => void, _includeCurrent: boolean) => {
53 backgroundCallbacks.push(apply);
54 }),
55 onForegroundStay: vi.fn((apply: () => void) => {
56 foregroundStayCallbacks.push(apply);
57 }),
58 ...overrides,
59 },
60 };
61}
62
63function makeAvailableUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {
64 return makeUpdateClient({
65 checkForUpdateAsync: vi.fn(async () => ({
66 isAvailable: true,
67 isRollBackToEmbedded: false,
68 })),
69 ...overrides,
70 });
71}
72
3473describe("runAppUpdateCheck", () => {
3574 it("does nothing while running from the Metro development server", async () => {
3675 vi.stubGlobal("__DEV__", true);
......@@ -45,23 +84,313 @@ describe("runAppUpdateCheck", () => {
4584 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
4685 });
4786
48 it("downloads and restarts when a new update is available", async () => {
49 const client = makeUpdateClient({
50 checkForUpdateAsync: vi.fn(async () => ({
51 isAvailable: true,
52 isRollBackToEmbedded: false,
53 })),
54 });
87 it("downloads silently and installs at the next backgrounding", async () => {
88 const client = makeAvailableUpdateClient();
89 const { backgroundCallbacks, environment } = makeUpdateEnvironment();
90 const deferral = createAppUpdateDeferral();
5591 const states: AppUpdateCheckState[] = [];
5692
57 await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) });
93 await runAppUpdateCheck({
94 client,
95 deferral,
96 environment,
97 onStateChange: (state) => states.push(state),
98 });
5899
59100 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
60101 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
102 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
103 expect(client.reloadAsync).not.toHaveBeenCalled();
104 expect(states).toEqual(["checking", "downloading", "ready"]);
105 expect(deferral.pendingInstall).toBe(true);
106 expect(backgroundCallbacks).toHaveLength(1);
107
108 backgroundCallbacks[0]!();
109 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
110 expect(environment.flushPendingWrites).toHaveBeenCalled();
111 });
112
113 it("flushes pending writes before restarting", async () => {
114 const client = makeAvailableUpdateClient();
115 const { environment } = makeUpdateEnvironment();
116
117 await runAppUpdateCheck({
118 applyMode: "immediate",
119 client,
120 deferral: createAppUpdateDeferral(),
121 environment,
122 });
123
124 const flushOrder = vi.mocked(environment.flushPendingWrites).mock.invocationCallOrder[0]!;
125 const reloadOrder = vi.mocked(client.reloadAsync).mock.invocationCallOrder[0]!;
126 expect(flushOrder).toBeLessThan(reloadOrder);
127 });
128
129 it("prompts once the app has stayed foregrounded with the download waiting", async () => {
130 const client = makeAvailableUpdateClient();
131 const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();
132 const deferral = createAppUpdateDeferral();
133
134 await runAppUpdateCheck({ client, deferral, environment });
135 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
136 expect(foregroundStayCallbacks).toHaveLength(1);
137
138 foregroundStayCallbacks[0]!();
139 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
140 expect(environment.confirmInstallNow).toHaveBeenCalledOnce();
141 expect(environment.flushPendingWrites).toHaveBeenCalled();
142 });
143
144 it("keeps the background install armed when the foreground prompt is declined", async () => {
145 const client = makeAvailableUpdateClient();
146 const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({
147 confirmInstallNow: vi.fn(async () => false),
148 });
149 const deferral = createAppUpdateDeferral();
150
151 await runAppUpdateCheck({ client, deferral, environment });
152
153 foregroundStayCallbacks[0]!();
154 await vi.waitFor(() => expect(environment.confirmInstallNow).toHaveBeenCalledOnce());
155 expect(client.reloadAsync).not.toHaveBeenCalled();
156 expect(deferral.pendingInstall).toBe(true);
157
158 backgroundCallbacks[0]!();
159 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
160 });
161
162 it("skips the foreground prompt once the install is no longer pending", async () => {
163 const client = makeAvailableUpdateClient();
164 const { environment, foregroundStayCallbacks } = makeUpdateEnvironment();
165 const deferral = createAppUpdateDeferral();
166
167 await runAppUpdateCheck({ client, deferral, environment });
168
169 // A failed deferred reload resets the deferral before the stay fires.
170 deferral.pendingInstall = false;
171 foregroundStayCallbacks[0]!();
172
173 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
174 expect(client.reloadAsync).not.toHaveBeenCalled();
175 });
176
177 it("re-arms instead of restarting when the app is no longer safely backgrounded", async () => {
178 const client = makeAvailableUpdateClient();
179 const safe = vi.fn(async () => false);
180 const { backgroundCallbacks, environment } = makeUpdateEnvironment({
181 isSafeToRestartInBackground: safe,
182 });
183 const deferral = createAppUpdateDeferral();
184
185 await runAppUpdateCheck({ client, deferral, environment });
186 expect(backgroundCallbacks).toHaveLength(1);
187 // Arming may fire for an already-backgrounded app…
188 expect(vi.mocked(environment.onNextBackground).mock.calls[0]![1]).toBe(true);
189
190 backgroundCallbacks[0]!();
191 await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));
192 expect(client.reloadAsync).not.toHaveBeenCalled();
193 expect(deferral.pendingInstall).toBe(true);
194 // …but a re-arm must wait for a fresh transition, or an unsafe attempt
195 // would retry in a tight loop within the same background session.
196 expect(vi.mocked(environment.onNextBackground).mock.calls[1]![1]).toBe(false);
197
198 safe.mockResolvedValue(true);
199 backgroundCallbacks[1]!();
200 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
201 });
202
203 it("resets the deferral when the deferred restart fails", async () => {
204 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
205 const client = makeAvailableUpdateClient({
206 reloadAsync: vi.fn(async () => {
207 throw new Error("reload rejected");
208 }),
209 });
210 const { backgroundCallbacks, environment } = makeUpdateEnvironment();
211 const deferral = createAppUpdateDeferral();
212
213 await runAppUpdateCheck({ client, deferral, environment });
214 backgroundCallbacks[0]!();
215
216 await vi.waitFor(() => expect(deferral.pendingInstall).toBe(false));
217 reportError.mockRestore();
218 });
219
220 it("arms the deferred install once across repeated checks", async () => {
221 const client = makeAvailableUpdateClient();
222 const { environment } = makeUpdateEnvironment();
223 const deferral = createAppUpdateDeferral();
224
225 await runAppUpdateCheck({ client, deferral, environment });
226 await runAppUpdateCheck({ client, deferral, environment });
227
228 expect(environment.onNextBackground).toHaveBeenCalledOnce();
229 expect(environment.onForegroundStay).toHaveBeenCalledOnce();
230 });
231
232 it("restarts into an already-downloaded update when the user asks to install", async () => {
233 const client = makeUpdateClient();
234 const { environment } = makeUpdateEnvironment();
235 const deferral = createAppUpdateDeferral();
236 deferral.pendingInstall = true;
237
238 await runAppUpdateCheck({ applyMode: "immediate", client, deferral, environment });
239
240 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
241 expect(client.reloadAsync).toHaveBeenCalledOnce();
242 });
243
244 it("honors an immediate request that joined an in-flight background check", async () => {
245 let resolveCheck!: (result: {
246 readonly isAvailable: boolean;
247 readonly isRollBackToEmbedded: boolean;
248 }) => void;
249 const checkResult = new Promise<{
250 readonly isAvailable: boolean;
251 readonly isRollBackToEmbedded: boolean;
252 }>((resolve) => {
253 resolveCheck = resolve;
254 });
255 const client = makeUpdateClient({
256 checkForUpdateAsync: vi.fn(() => checkResult),
257 });
258 const { environment } = makeUpdateEnvironment();
259 const deferral = createAppUpdateDeferral();
260
261 const backgroundCheck = runAppUpdateCheck({ client, deferral, environment });
262 const manualCheck = runAppUpdateCheck({
263 applyMode: "immediate",
264 client,
265 deferral,
266 environment,
267 });
268
269 resolveCheck({ isAvailable: true, isRollBackToEmbedded: false });
270 await Promise.all([backgroundCheck, manualCheck]);
271
272 // The coalesced background check deferred the download, but the manual
273 // caller explicitly asked to install, so the restart happens anyway.
274 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
275 expect(client.reloadAsync).toHaveBeenCalledOnce();
276 });
277
278 it("runs a single restart when the deferred install races the foreground prompt", async () => {
279 const client = makeAvailableUpdateClient();
280 let releaseFlush!: () => void;
281 const blockedFlush = new Promise<void>((resolve) => {
282 releaseFlush = resolve;
283 });
284 const flushPendingWrites = vi.fn(async (): Promise<void> => {});
285 const { backgroundCallbacks, environment, foregroundStayCallbacks } = makeUpdateEnvironment({
286 flushPendingWrites,
287 });
288 const deferral = createAppUpdateDeferral();
289
290 await runAppUpdateCheck({ client, deferral, environment });
291 flushPendingWrites.mockReturnValue(blockedFlush);
292
293 // The deferred install starts and blocks on its flush; the foreground
294 // prompt firing in that window must not begin a second restart.
295 backgroundCallbacks[0]!();
296 await vi.waitFor(() => expect(flushPendingWrites).toHaveBeenCalledOnce());
297 foregroundStayCallbacks[0]!();
298 await new Promise((resolve) => setTimeout(resolve, 0));
299 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
300
301 releaseFlush();
302 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
303 });
304
305 it("holds the deferred restart and re-arms when the pre-restart flush fails", async () => {
306 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
307 const client = makeAvailableUpdateClient();
308 const { backgroundCallbacks, environment } = makeUpdateEnvironment({
309 flushPendingWrites: vi.fn(async () => {
310 throw new Error("disk full");
311 }),
312 });
313 const deferral = createAppUpdateDeferral();
314
315 await runAppUpdateCheck({ client, deferral, environment });
316 backgroundCallbacks[0]!();
317
318 await vi.waitFor(() => expect(backgroundCallbacks).toHaveLength(2));
319 expect(client.reloadAsync).not.toHaveBeenCalled();
320 expect(deferral.pendingInstall).toBe(true);
321 reportError.mockRestore();
322 });
323
324 it("restarts without prompting when the caller asked for an immediate install", async () => {
325 const client = makeAvailableUpdateClient();
326 const { environment } = makeUpdateEnvironment();
327 const states: AppUpdateCheckState[] = [];
328
329 await runAppUpdateCheck({
330 applyMode: "immediate",
331 client,
332 deferral: createAppUpdateDeferral(),
333 environment,
334 onStateChange: (state) => states.push(state),
335 });
336
337 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
61338 expect(client.reloadAsync).toHaveBeenCalledOnce();
62339 expect(states).toEqual(["checking", "downloading", "restarting"]);
63340 });
64341
342 it("holds an automatic rollback restart when the flush fails and re-arms it", async () => {
343 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
344 const client = makeUpdateClient({
345 checkForUpdateAsync: vi.fn(async () => ({
346 isAvailable: false,
347 isRollBackToEmbedded: true,
348 })),
349 fetchUpdateAsync: vi.fn(async () => ({
350 isNew: false,
351 isRollBackToEmbedded: true,
352 })),
353 });
354 const flushPendingWrites = vi.fn(async (): Promise<void> => {
355 throw new Error("storage unavailable");
356 });
357 const { backgroundCallbacks, environment } = makeUpdateEnvironment({ flushPendingWrites });
358 const deferral = createAppUpdateDeferral();
359
360 await runAppUpdateCheck({ client, deferral, environment });
361
362 // Nobody asked for this restart, so it must not discard the state it
363 // failed to land; the rollback waits armed for the next backgrounding.
364 expect(client.reloadAsync).not.toHaveBeenCalled();
365 expect(deferral.pendingInstall).toBe(true);
366 expect(backgroundCallbacks).toHaveLength(1);
367
368 flushPendingWrites.mockResolvedValue(undefined);
369 backgroundCallbacks[0]!();
370 await vi.waitFor(() => expect(client.reloadAsync).toHaveBeenCalledOnce());
371 reportError.mockRestore();
372 });
373
374 it("still restarts a user-requested install when the flush fails", async () => {
375 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
376 const client = makeAvailableUpdateClient();
377 const { environment } = makeUpdateEnvironment({
378 flushPendingWrites: vi.fn(async () => {
379 throw new Error("storage unavailable");
380 }),
381 });
382
383 await runAppUpdateCheck({
384 applyMode: "immediate",
385 client,
386 deferral: createAppUpdateDeferral(),
387 environment,
388 });
389
390 expect(client.reloadAsync).toHaveBeenCalledOnce();
391 reportError.mockRestore();
392 });
393
65394 it("restarts into the embedded bundle for a rollback directive", async () => {
66395 const client = makeUpdateClient({
67396 checkForUpdateAsync: vi.fn(async () => ({
......@@ -73,10 +402,13 @@ describe("runAppUpdateCheck", () => {
73402 isRollBackToEmbedded: true,
74403 })),
75404 });
405 const { environment } = makeUpdateEnvironment();
76406
77 await runAppUpdateCheck({ client });
407 await runAppUpdateCheck({ client, deferral: createAppUpdateDeferral(), environment });
78408
79409 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
410 // A rollback pulls a broken bundle, so it never waits on the prompt.
411 expect(environment.confirmInstallNow).not.toHaveBeenCalled();
80412 expect(client.reloadAsync).toHaveBeenCalledOnce();
81413 });
82414
......@@ -276,6 +608,36 @@ describe("createAppUpdateLaunchCheck", () => {
276608 });
277609});
278610
611describe("shouldRecheckAppUpdateOnForeground", () => {
612 it("requires a meaningful background gap", () => {
613 expect(shouldRecheckAppUpdateOnForeground(null, 100_000, false)).toBe(false);
614 expect(
615 shouldRecheckAppUpdateOnForeground(
616 100_000,
617 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS - 1,
618 false,
619 ),
620 ).toBe(false);
621 expect(
622 shouldRecheckAppUpdateOnForeground(
623 100_000,
624 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
625 false,
626 ),
627 ).toBe(true);
628 });
629
630 it("stays quiet while a downloaded update waits for its install", () => {
631 expect(
632 shouldRecheckAppUpdateOnForeground(
633 100_000,
634 100_000 + FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS,
635 true,
636 ),
637 ).toBe(false);
638 });
639});
640
279641describe("registerHiddenUpdateTap", () => {
280642 it("unlocks the manual check on the fifth tap", () => {
281643 let count = 0;
apps/mobile/src/features/updates/app-updates.ts+378-1
......@@ -8,7 +8,13 @@ import {
88 squashAtomCommandFailure,
99} from "@t3tools/client-runtime/state/runtime";
1010
11export type AppUpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current";
11export type AppUpdateCheckState =
12 | "idle"
13 | "checking"
14 | "downloading"
15 | "ready"
16 | "restarting"
17 | "current";
1218
1319export interface AppUpdateClient {
1420 readonly isEnabled: boolean;
......@@ -23,8 +29,70 @@ export interface AppUpdateClient {
2329 readonly reloadAsync: () => Promise<void>;
2430}
2531
32/**
33 * The pieces of the app the update flow has to coordinate with before it may
34 * tear down the JavaScript runtime. Injectable so the flow stays unit-testable.
35 */
36export interface AppUpdateEnvironment {
37 /** Asks the user to install the waiting update now; `false` keeps it deferred. */
38 readonly confirmInstallNow: () => Promise<boolean>;
39 /**
40 * Lands persisted state (drafts, outbox) before the restart. Rejects when a
41 * write failed, so a silent restart can hold off instead of dropping the
42 * unsaved in-memory state.
43 */
44 readonly flushPendingWrites: () => Promise<void>;
45 /**
46 * Whether a deferred restart may fire right now: the app must still be
47 * backgrounded (flush latency or an iOS suspend can push the continuation
48 * into the next foreground session) and not merely paused behind an
49 * app-initiated handoff like the Android image picker.
50 */
51 readonly isSafeToRestartInBackground: () => Promise<boolean>;
52 /**
53 * Runs `apply` the next time the app enters the background. With
54 * `includeCurrent`, an app that is already backgrounded fires immediately
55 * (so a backgrounding that raced module load is not missed); without it,
56 * only a future transition fires, so an attempt that already failed in the
57 * current background session cannot retry in a tight loop.
58 */
59 readonly onNextBackground: (apply: () => void, includeCurrent: boolean) => void;
60 /**
61 * Runs `apply` once the app has stayed foregrounded for the whole prompt
62 * window — the signal that a deferred install has had no backgrounding to
63 * ride on.
64 */
65 readonly onForegroundStay: (apply: () => void) => void;
66}
67
68/** Tracks a downloaded update waiting for a safe moment to install. */
69export interface AppUpdateDeferral {
70 pendingInstall: boolean;
71 /**
72 * Claimed by whichever restart sequence (deferred backgrounding, foreground
73 * prompt, manual install) starts first, so racing paths cannot tear down
74 * the runtime twice.
75 */
76 installInProgress: boolean;
77}
78
79export function createAppUpdateDeferral(): AppUpdateDeferral {
80 return { pendingInstall: false, installInProgress: false };
81}
82
83const appUpdateDeferral = createAppUpdateDeferral();
84
2685interface AppUpdateCheckOptions {
86 /**
87 * "background" (default) installs silently at the next backgrounding,
88 * asking only if the app then stays foregrounded so long that the install
89 * never gets its chance. "immediate" restarts as soon as the download
90 * lands — reserved for flows where the user explicitly requested the update.
91 */
92 readonly applyMode?: "background" | "immediate";
2793 readonly client?: AppUpdateClient;
94 readonly deferral?: AppUpdateDeferral;
95 readonly environment?: AppUpdateEnvironment;
2896 readonly onFailure?: (message: string) => void;
2997 readonly onStateChange?: (state: AppUpdateCheckState) => void;
3098}
......@@ -86,6 +154,15 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr
86154
87155 if (appUpdateCheckInFlight) {
88156 await observeAppUpdateCheck(appUpdateCheckInFlight, options);
157 // A background-mode check in flight may have deferred the download this
158 // caller explicitly asked to install; honor the explicit request now.
159 if (options.applyMode === "immediate") {
160 const deferral = options.deferral ?? appUpdateDeferral;
161 if (deferral.pendingInstall) {
162 const environment = options.environment ?? defaultAppUpdateEnvironment;
163 await installPendingAppUpdate(client, environment, deferral, options);
164 }
165 }
89166 return;
90167 }
91168
......@@ -109,6 +186,9 @@ export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Pr
109186 appUpdateCheckInFlight = inFlight;
110187
111188 const execution = performAppUpdateCheck(client, {
189 applyMode: options.applyMode,
190 deferral: options.deferral,
191 environment: options.environment,
112192 onFailure: (message) => {
113193 progress.failure = message;
114194 notifyListeners(failureListeners, message);
......@@ -175,6 +255,15 @@ async function performAppUpdateCheck(
175255 options: AppUpdateCheckOptions,
176256): Promise<void> {
177257 const setState = options.onStateChange ?? (() => {});
258 const environment = options.environment ?? defaultAppUpdateEnvironment;
259 const deferral = options.deferral ?? appUpdateDeferral;
260
261 // The user explicitly asked to install and a previous check has already
262 // downloaded the update; restart into it without another network round trip.
263 if (options.applyMode === "immediate" && deferral.pendingInstall) {
264 await installPendingAppUpdate(client, environment, deferral, options);
265 return;
266 }
178267
179268 setState("checking");
180269 const check = await settlePromise(() => client.checkForUpdateAsync());
......@@ -203,14 +292,252 @@ async function performAppUpdateCheck(
203292 return;
204293 }
205294
295 // A rollback directive exists to pull a broken bundle; never hold it
296 // behind a prompt or a deferred install.
297 if (options.applyMode === "immediate" || fetched.value.isRollBackToEmbedded) {
298 const outcome = await installAppUpdate(
299 client,
300 environment,
301 deferral,
302 options,
303 options.applyMode === "immediate",
304 );
305 if (outcome === "flush-failed") {
306 // Only reachable for an automatic rollback: keep the state-bearing
307 // runtime alive and retry like a deferred install. The fetched rollback
308 // still applies at the next cold start regardless.
309 setState("ready");
310 armDeferredAppUpdateInstall(client, environment, deferral);
311 }
312 return;
313 }
314
315 setState("ready");
316 armDeferredAppUpdateInstall(client, environment, deferral);
317}
318
319type AppUpdateInstallOutcome = "installed" | "flush-failed" | "restart-failed";
320
321/**
322 * Restarting mid-session while native surfaces are mounted is the crashiest
323 * moment expo-updates has, so the restart flushes persistence first and, by
324 * default, waits for a backgrounding — where nothing is rendering and the
325 * teardown is invisible. Only a restart the user explicitly asked for may
326 * proceed over a failed flush; an automatic one aborts with "flush-failed"
327 * so unsaved state is never silently discarded.
328 */
329async function installAppUpdate(
330 client: AppUpdateClient,
331 environment: AppUpdateEnvironment,
332 deferral: AppUpdateDeferral,
333 options: AppUpdateCheckOptions,
334 userRequested: boolean,
335): Promise<AppUpdateInstallOutcome> {
336 // A concurrent install sequence already owns the restart.
337 if (deferral.installInProgress) return "installed";
338 deferral.installInProgress = true;
339 const setState = options.onStateChange ?? (() => {});
206340 setState("restarting");
341 const flushed = await settlePromise(() => environment.flushPendingWrites());
342 if (flushed._tag === "Failure") {
343 reportUpdateFailure(flushed, "Could not save pending state.", undefined);
344 if (!userRequested) {
345 deferral.installInProgress = false;
346 return "flush-failed";
347 }
348 }
207349 const reloaded = await settlePromise(() => client.reloadAsync());
208350 if (reloaded._tag === "Failure") {
209351 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure);
210352 setState("idle");
353 deferral.installInProgress = false;
354 return "restart-failed";
355 }
356 return "installed";
357}
358
359/** Restarts into an already-downloaded update at the user's request. */
360async function installPendingAppUpdate(
361 client: AppUpdateClient,
362 environment: AppUpdateEnvironment,
363 deferral: AppUpdateDeferral,
364 options: AppUpdateCheckOptions,
365): Promise<void> {
366 const outcome = await installAppUpdate(client, environment, deferral, options, true);
367 if (outcome === "restart-failed") {
368 // Let later checks re-arm the install; the downloaded update still
369 // applies at the next cold start regardless.
370 deferral.pendingInstall = false;
371 }
372}
373
374function armDeferredAppUpdateInstall(
375 client: AppUpdateClient,
376 environment: AppUpdateEnvironment,
377 deferral: AppUpdateDeferral,
378): void {
379 if (deferral.pendingInstall) return;
380 deferral.pendingInstall = true;
381 scheduleDeferredAppUpdateInstall(client, environment, deferral, true);
382 environment.onForegroundStay(() => {
383 void promptDeferredAppUpdateInstall(client, environment, deferral);
384 });
385}
386
387/**
388 * A deferred install normally rides the next backgrounding, but a session that
389 * never leaves the foreground would sit on the download forever. Only then is
390 * the user asked, and declining simply leaves the background install armed.
391 */
392async function promptDeferredAppUpdateInstall(
393 client: AppUpdateClient,
394 environment: AppUpdateEnvironment,
395 deferral: AppUpdateDeferral,
396): Promise<void> {
397 if (!deferral.pendingInstall || deferral.installInProgress) return;
398 const installNow = await settlePromise(() => environment.confirmInstallNow());
399 if (installNow._tag !== "Success" || !installNow.value) return;
400 // A backgrounding while the alert was up may have started the deferred
401 // restart already; the stale accept must not start a second one.
402 if (!deferral.pendingInstall || deferral.installInProgress) return;
403 await installPendingAppUpdate(client, environment, deferral, {});
404}
405
406function scheduleDeferredAppUpdateInstall(
407 client: AppUpdateClient,
408 environment: AppUpdateEnvironment,
409 deferral: AppUpdateDeferral,
410 includeCurrent: boolean,
411): void {
412 environment.onNextBackground(() => {
413 void applyDeferredAppUpdateInstall(client, environment, deferral);
414 }, includeCurrent);
415}
416
417async function applyDeferredAppUpdateInstall(
418 client: AppUpdateClient,
419 environment: AppUpdateEnvironment,
420 deferral: AppUpdateDeferral,
421): Promise<void> {
422 if (!deferral.pendingInstall || deferral.installInProgress) return;
423 deferral.installInProgress = true;
424 const flushed = await settlePromise(() => environment.flushPendingWrites());
425 const safe = await settlePromise(() => environment.isSafeToRestartInBackground());
426 if (flushed._tag === "Failure" || safe._tag !== "Success" || !safe.value) {
427 if (flushed._tag === "Failure") {
428 // Nothing is lost yet: keep the state-bearing runtime alive and retry
429 // the flush at the next backgrounding instead of restarting over it.
430 reportUpdateFailure(flushed, "Could not save pending state.", undefined);
431 }
432 deferral.installInProgress = false;
433 // This attempt already ran in the current background session; retrying
434 // before a fresh transition would just loop over the same failure.
435 scheduleDeferredAppUpdateInstall(client, environment, deferral, false);
436 return;
437 }
438 const reloaded = await settlePromise(() => client.reloadAsync());
439 if (reloaded._tag === "Failure") {
440 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", undefined);
441 deferral.installInProgress = false;
442 // Let later checks re-arm the install; the downloaded update still
443 // applies at the next cold start regardless.
444 deferral.pendingInstall = false;
211445 }
212446}
213447
448async function defaultConfirmInstallNow(): Promise<boolean> {
449 const { Alert } = await import("react-native");
450 return new Promise<boolean>((resolve) => {
451 Alert.alert(
452 "Update ready",
453 "A new version has been downloaded and installs automatically the next time you leave the app. Install it now instead?",
454 [
455 { onPress: () => resolve(false), style: "cancel", text: "Later" },
456 { onPress: () => resolve(true), text: "Install Now" },
457 ],
458 { cancelable: true, onDismiss: () => resolve(false) },
459 );
460 });
461}
462
463async function defaultFlushPendingWrites(): Promise<void> {
464 // Attempt every flush before surfacing the first failure, so one broken
465 // store cannot keep the others from landing.
466 const results = await Promise.allSettled([
467 import("../../state/use-composer-drafts").then((drafts) => drafts.flushComposerDrafts()),
468 import("../../state/thread-outbox").then((outbox) => outbox.flushThreadOutbox()),
469 ]);
470 const failed = results.find(
471 (result): result is PromiseRejectedResult => result.status === "rejected",
472 );
473 if (failed) throw failed.reason;
474}
475
476async function defaultIsSafeToRestartInBackground(): Promise<boolean> {
477 const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff");
478 if (isForegroundHandoffActive()) return false;
479 const { AppState } = await import("react-native");
480 return AppState.currentState === "background";
481}
482
483function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void {
484 void import("react-native").then(({ AppState }) => {
485 const subscription = AppState.addEventListener("change", (state) => {
486 if (state !== "background") return;
487 subscription.remove();
488 apply();
489 });
490 // The app may already have backgrounded while this module was loading;
491 // the listener alone would then wait a whole extra foreground cycle.
492 if (includeCurrent && AppState.currentState === "background") {
493 subscription.remove();
494 apply();
495 }
496 });
497}
498
499/**
500 * How long the app may stay foregrounded with a downloaded update before the
501 * install prompt appears. Long enough that most sessions background naturally
502 * and install silently instead.
503 */
504export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000;
505
506/**
507 * The window resets on every backgrounding because that is exactly when the
508 * deferred install gets its chance. iOS "inactive" blips (app switcher, a
509 * pulled-down notification shade) leave the timer running.
510 */
511function defaultOnForegroundStay(apply: () => void): void {
512 void import("react-native").then(({ AppState }) => {
513 let timer: ReturnType<typeof setTimeout> | undefined;
514 const arm = () => {
515 timer ??= setTimeout(() => {
516 subscription.remove();
517 apply();
518 }, DEFERRED_INSTALL_PROMPT_AFTER_MS);
519 };
520 const disarm = () => {
521 if (timer === undefined) return;
522 clearTimeout(timer);
523 timer = undefined;
524 };
525 const subscription = AppState.addEventListener("change", (state) => {
526 if (state === "active") arm();
527 else if (state === "background") disarm();
528 });
529 if (AppState.currentState === "active") arm();
530 });
531}
532
533const defaultAppUpdateEnvironment: AppUpdateEnvironment = {
534 confirmInstallNow: defaultConfirmInstallNow,
535 flushPendingWrites: defaultFlushPendingWrites,
536 isSafeToRestartInBackground: defaultIsSafeToRestartInBackground,
537 onNextBackground: defaultOnNextBackground,
538 onForegroundStay: defaultOnForegroundStay,
539};
540
214541function reportUpdateFailure(
215542 result: AtomCommandResult<unknown, unknown>,
216543 fallback: string,
......@@ -243,3 +570,53 @@ export function createAppUpdateLaunchCheck(
243570}
244571
245572export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck();
573
574/**
575 * The app can stay resident for days, so a launch-only check misses updates
576 * published while it was in memory. Anything shorter reads as noise: brief
577 * app switches should not trigger network checks or an install prompt.
578 */
579export const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000;
580
581export function shouldRecheckAppUpdateOnForeground(
582 backgroundedAtMs: number | null,
583 activeAtMs: number,
584 pendingInstall: boolean,
585): boolean {
586 if (pendingInstall) return false;
587 return (
588 backgroundedAtMs !== null &&
589 activeAtMs - backgroundedAtMs >= FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS
590 );
591}
592
593export function createAppUpdateForegroundRecheck(
594 client: AppUpdateClient = Updates,
595 deferral: AppUpdateDeferral = appUpdateDeferral,
596): () => void {
597 let started = false;
598
599 return () => {
600 if (started || !isAppUpdateCheckAvailable(client)) return;
601 started = true;
602 void import("react-native").then(({ AppState }) => {
603 let backgroundedAtMs: number | null = null;
604 AppState.addEventListener("change", (state) => {
605 if (state === "background") {
606 backgroundedAtMs = Date.now();
607 return;
608 }
609 if (state !== "active") return;
610 const shouldCheck = shouldRecheckAppUpdateOnForeground(
611 backgroundedAtMs,
612 Date.now(),
613 deferral.pendingInstall,
614 );
615 backgroundedAtMs = null;
616 if (shouldCheck) void runAppUpdateCheck({ client, deferral });
617 });
618 });
619 };
620}
621
622export const startAppUpdateForegroundRecheck = createAppUpdateForegroundRecheck();
apps/mobile/src/lib/atomic-file.ts created+19
......@@ -0,0 +1,19 @@
1import type { File } from "expo-file-system";
2
3let tempFileSequence = 0;
4
5/**
6 * Replaces a file's contents through a sibling temp file and an overwriting
7 * rename, so an interrupted write (app restart, process death) never leaves a
8 * truncated document at the final path. Each write stages through its own
9 * temp file so concurrent writers to the same destination cannot move or
10 * clobber each other's staging file mid-flight.
11 */
12export async function writeFileAtomically(file: File, contents: string): Promise<void> {
13 const { File: FileConstructor } = await import("expo-file-system");
14 tempFileSequence += 1;
15 const temp = new FileConstructor(file.parentDirectory, `${file.name}.${tempFileSequence}.tmp`);
16 temp.create({ intermediates: true, overwrite: true });
17 temp.write(contents);
18 temp.moveSync(file, { overwrite: true });
19}
apps/mobile/src/lib/composerImages.ts+16-7
......@@ -4,6 +4,7 @@ import {
44 type UploadChatImageAttachment,
55} from "@t3tools/contracts";
66import { estimateBase64ByteSize } from "./base64";
7import { beginForegroundHandoff } from "./foreground-handoff";
78import { uuidv4 } from "./uuid";
89
910export interface DraftComposerImageAttachment extends UploadChatImageAttachment {
......@@ -65,13 +66,21 @@ export async function pickComposerImages(input: { readonly existingCount: number
6566 };
6667 }
6768
68 const result = await imagePicker.launchImageLibraryAsync({
69 mediaTypes: ["images"],
70 allowsMultipleSelection: true,
71 selectionLimit: remainingSlots,
72 base64: true,
73 quality: 1,
74 });
69 // The picker covers the Android activity, which reports the app as
70 // backgrounded; the guard keeps background-triggered restarts away mid-pick.
71 const endHandoff = beginForegroundHandoff();
72 let result: Awaited<ReturnType<typeof imagePicker.launchImageLibraryAsync>>;
73 try {
74 result = await imagePicker.launchImageLibraryAsync({
75 mediaTypes: ["images"],
76 allowsMultipleSelection: true,
77 selectionLimit: remainingSlots,
78 base64: true,
79 quality: 1,
80 });
81 } finally {
82 endHandoff();
83 }
7584
7685 if (result.canceled) {
7786 return {
apps/mobile/src/lib/foreground-handoff.test.ts created+32
......@@ -0,0 +1,32 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { beginForegroundHandoff, isForegroundHandoffActive } from "./foreground-handoff";
4
5describe("foreground handoff", () => {
6 it("is active only while a handoff is open", () => {
7 expect(isForegroundHandoffActive()).toBe(false);
8 const end = beginForegroundHandoff();
9 expect(isForegroundHandoffActive()).toBe(true);
10 end();
11 expect(isForegroundHandoffActive()).toBe(false);
12 });
13
14 it("stays active until every overlapping handoff ends", () => {
15 const endFirst = beginForegroundHandoff();
16 const endSecond = beginForegroundHandoff();
17 endFirst();
18 expect(isForegroundHandoffActive()).toBe(true);
19 endSecond();
20 expect(isForegroundHandoffActive()).toBe(false);
21 });
22
23 it("tolerates an end function called twice", () => {
24 const endFirst = beginForegroundHandoff();
25 const endSecond = beginForegroundHandoff();
26 endFirst();
27 endFirst();
28 expect(isForegroundHandoffActive()).toBe(true);
29 endSecond();
30 expect(isForegroundHandoffActive()).toBe(false);
31 });
32});
apps/mobile/src/lib/foreground-handoff.ts created+22
......@@ -0,0 +1,22 @@
1/**
2 * Tracks app-initiated OS-surface handoffs (image picker, auth tab, share
3 * sheet). Android reports the app as backgrounded while one of these covers
4 * the activity, so background-triggered work — like a deferred app update
5 * restart — has to wait them out instead of tearing down the mid-flow runtime.
6 */
7let activeHandoffs = 0;
8
9/** Returns an idempotent end function; call it when the handoff resolves. */
10export function beginForegroundHandoff(): () => void {
11 activeHandoffs += 1;
12 let ended = false;
13 return () => {
14 if (ended) return;
15 ended = true;
16 activeHandoffs -= 1;
17 };
18}
19
20export function isForegroundHandoffActive(): boolean {
21 return activeHandoffs > 0;
22}
apps/mobile/src/state/thread-outbox-storage.ts+25-5
......@@ -1,6 +1,7 @@
11import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts";
22import * as Schema from "effect/Schema";
33
4import { writeFileAtomically } from "../lib/atomic-file";
45import {
56 decodeQueuedThreadMessage,
67 encodeQueuedThreadMessage,
......@@ -9,6 +10,24 @@ import {
910
1011const THREAD_OUTBOX_DIRECTORY = "thread-outbox";
1112
13const inFlightWrites = new Set<Promise<void>>();
14
15function trackInFlightWrite(operation: Promise<void>): Promise<void> {
16 inFlightWrites.add(operation);
17 void operation.catch(() => undefined).finally(() => inFlightWrites.delete(operation));
18 return operation;
19}
20
21/**
22 * Awaits queued-message writes so an app update restart cannot tear down the
23 * runtime while one is mid-file.
24 */
25export async function flushThreadOutboxWrites(): Promise<void> {
26 while (inFlightWrites.size > 0) {
27 await Promise.allSettled(inFlightWrites);
28 }
29}
30
1231export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutboxStorageError>()(
1332 "ThreadOutboxStorageError",
1433 {
......@@ -89,11 +108,12 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = {
89108 write: async (message) => {
90109 const fileName = messageFileName(message.messageId);
91110 try {
92 const file = await getMessageFile(message.messageId);
93 if (!file.exists) {
94 file.create({ intermediates: true, overwrite: true });
95 }
96 file.write(JSON.stringify(encodeQueuedThreadMessage(message)));
111 await trackInFlightWrite(
112 (async () => {
113 const file = await getMessageFile(message.messageId);
114 await writeFileAtomically(file, JSON.stringify(encodeQueuedThreadMessage(message)));
115 })(),
116 );
97117 } catch (cause) {
98118 throw new ThreadOutboxStorageError({
99119 operation: "write",
apps/mobile/src/state/thread-outbox.ts+12-1
......@@ -3,7 +3,7 @@ import type { EnvironmentId } from "@t3tools/contracts";
33import { appAtomRegistry } from "./atom-registry";
44import { createThreadOutboxManager } from "./thread-outbox-manager";
55import type { QueuedThreadMessage } from "./thread-outbox-model";
6import { expoThreadOutboxStorage } from "./thread-outbox-storage";
6import { expoThreadOutboxStorage, flushThreadOutboxWrites } from "./thread-outbox-storage";
77
88export * from "./thread-outbox-model";
99
......@@ -12,6 +12,17 @@ export const threadOutboxManager = createThreadOutboxManager({
1212 storage: expoThreadOutboxStorage,
1313});
1414
15/**
16 * Lands queued outbox mutations before the JS runtime is torn down (app update
17 * restart). An enqueued message is published to the atom immediately but its
18 * durable write waits behind the mutation queue, so draining only the writes
19 * already mid-file would miss it.
20 */
21export async function flushThreadOutbox(): Promise<void> {
22 await threadOutboxManager.serialize(async () => {});
23 await flushThreadOutboxWrites();
24}
25
1526export function ensureThreadOutboxLoaded(): void {
1627 void threadOutboxManager.load();
1728}
apps/mobile/src/state/use-composer-drafts.test.ts+39
......@@ -4,6 +4,7 @@ import { vi } from "vite-plus/test";
44
55const composerDraftFileMocks = vi.hoisted(() => {
66 let document = "";
7 let writeError: Error | null = null;
78 let releaseRead: (() => void) | null = null;
89 let readBarrier = Promise.resolve();
910
......@@ -17,23 +18,35 @@ const composerDraftFileMocks = vi.hoisted(() => {
1718 releaseRead?.();
1819 releaseRead = null;
1920 },
21 getDocument() {
22 return document;
23 },
2024 setDocument(value: unknown) {
2125 document = JSON.stringify(value);
2226 },
27 setWriteError(error: Error | null) {
28 writeError = error;
29 },
2330 Directory: class {
2431 create() {}
2532 },
2633 File: class {
2734 exists = true;
35 parentDirectory = null;
2836
2937 create() {}
3038
39 moveSync() {}
40
3141 async text() {
3242 await readBarrier;
3343 return document;
3444 }
3545
3646 write(value: string) {
47 if (writeError) {
48 throw writeError;
49 }
3750 document = value;
3851 }
3952 },
......@@ -49,15 +62,18 @@ vi.mock("expo-file-system", () => ({
4962import { appAtomRegistry } from "./atom-registry";
5063import {
5164 clearComposerDraftContentState,
65 ComposerDraftPersistenceError,
5266 composerDraftsAtom,
5367 copyComposerDraftContentIfEmpty,
5468 copyComposerDraftContentState,
5569 decodePersistedComposerDrafts,
5670 type ComposerDraft,
71 flushComposerDrafts,
5772 getComposerDraftSnapshot,
5873 mergeComposerDraftContentState,
5974 removeComposerDraftsForEnvironment,
6075 restoreComposerDraftSnapshotState,
76 setComposerDraftText,
6177} from "./use-composer-drafts";
6278
6379const DRAFT: ComposerDraft = {
......@@ -393,4 +409,27 @@ describe("mobile composer drafts", () => {
393409 [unrelatedKey]: unrelated,
394410 });
395411 });
412
413 it("lands a still-debounced draft write when flushed", async () => {
414 const draftKey = "environment-1:thread-1";
415 setComposerDraftText(draftKey, "typed right before the restart");
416
417 await flushComposerDrafts();
418
419 expect(JSON.parse(composerDraftFileMocks.getDocument())).toMatchObject({
420 drafts: { [draftKey]: { text: "typed right before the restart" } },
421 });
422 });
423
424 it("propagates a flush write failure instead of resolving as saved", async () => {
425 const draftKey = "environment-1:thread-1";
426 setComposerDraftText(draftKey, "unsaved");
427 composerDraftFileMocks.setWriteError(new Error("storage unavailable"));
428
429 try {
430 await expect(flushComposerDrafts()).rejects.toBeInstanceOf(ComposerDraftPersistenceError);
431 } finally {
432 composerDraftFileMocks.setWriteError(null);
433 }
434 });
396435});
apps/mobile/src/state/use-composer-drafts.ts+23-5
......@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema";
1313import { useEffect } from "react";
1414import { Atom } from "effect/unstable/reactivity";
1515
16import { writeFileAtomically } from "../lib/atomic-file";
1617import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema";
1718import type { DraftComposerImageAttachment } from "../lib/composerImages";
1819import { SerializedAsyncQueue } from "../lib/serialized-async-queue";
......@@ -188,10 +189,7 @@ async function writePersistedComposerDrafts(drafts: Record<string, ComposerDraft
188189 } as const;
189190 const encoded = JSON.stringify(document);
190191 operation = "write";
191 if (!file.exists) {
192 file.create({ intermediates: true, overwrite: true });
193 }
194 file.write(encoded);
192 await writeFileAtomically(file, encoded);
195193 } catch (cause) {
196194 throw new ComposerDraftPersistenceError({
197195 operation,
......@@ -211,6 +209,26 @@ async function savePersistedComposerDrafts(drafts: Record<string, ComposerDraft>
211209 }
212210}
213211
212/**
213 * Lands any debounced or in-flight draft write before the JS runtime is torn
214 * down (app update restart), so the freshest draft state survives it. A write
215 * failure propagates so the caller can decide whether the restart may proceed.
216 */
217export async function flushComposerDrafts(): Promise<void> {
218 // An edit during an awaited write schedules another debounced write, so
219 // keep landing snapshots until no debounce is pending after a queue drain.
220 do {
221 while (persistTimer !== null) {
222 clearTimeout(persistTimer);
223 persistTimer = null;
224 await persistenceQueue.run(() =>
225 writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)),
226 );
227 }
228 await persistenceQueue.run(() => Promise.resolve());
229 } while (persistTimer !== null);
230}
231
214232function schedulePersistComposerDrafts(drafts: Record<string, ComposerDraft>): void {
215233 if (persistTimer !== null) {
216234 clearTimeout(persistTimer);
......@@ -627,7 +645,7 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
627645 persistTimer = null;
628646 }
629647 appAtomRegistry.set(composerDraftsAtom, next);
630 await writePersistedComposerDrafts(next);
648 await persistenceQueue.run(() => writePersistedComposerDrafts(next));
631649}
632650
633651export function useComposerDraft(draftKey: string | null): ComposerDraft {
docs/user/updating.md+8
......@@ -70,4 +70,12 @@ If a step fails:
70703. For a command-line server, relaunch it with `npx t3@<client-version>`, replacing
7171 `<client-version>` with the client version shown in the warning.
7272
73## The Mobile App
74
75The mobile app keeps itself current on its own. When it finds a new version, it downloads it in the
76background and installs it automatically the next time you leave the app. Unsent drafts and queued
77messages are saved before the restart. Only if the app stays open long enough that the update never
78gets that chance does it ask whether to install right away; choosing **Later** is safe and keeps the
79automatic install armed.
80
7381For remote connection setup and access troubleshooting, see [Remote Access](./remote-access.md).