diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 7bf7747fef2c7..0ffb2e6e5aa18 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -125,6 +125,8 @@ export function createComposerAttachmentUploadWorker( ReadonlyArray > = composerAttachmentUploadRequestsAtom, ) { + // This is where draft hydration starts at app start; the draft read hooks + // rely on it and do not start it themselves. ensureComposerDraftsLoaded(); const queue = createComposerAttachmentUploadQueue({ onChange: (states) => appAtomRegistry.set(composerAttachmentUploadsAtom, states), diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a10431d802b3d..8ced70786e13f 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -31,6 +31,7 @@ import { onTestFinished, vi } from "vite-plus/test"; const composerDraftFileMocks = vi.hoisted(() => { let document = JSON.stringify({ schemaVersion: 1, drafts: {} }); let readError: Error | null = null; + let readErrorsRemaining = Number.POSITIVE_INFINITY; let writeError: Error | null = null; let releaseRead: (() => void) | null = null; let readBarrier = Promise.resolve(); @@ -56,8 +57,9 @@ const composerDraftFileMocks = vi.hoisted(() => { setDocument(value: unknown) { document = JSON.stringify(value); }, - setReadError(error: Error | null) { + setReadError(error: Error | null, times = Number.POSITIVE_INFINITY) { readError = error; + readErrorsRemaining = times; }, setWriteError(error: Error | null) { writeError = error; @@ -91,7 +93,10 @@ const composerDraftFileMocks = vi.hoisted(() => { async text() { await readBarrier; - if (readError) throw readError; + if (readError && readErrorsRemaining > 0) { + readErrorsRemaining -= 1; + throw readError; + } return document; } @@ -185,6 +190,7 @@ import { clearComposerDraftsEnvironment, ComposerDraftPersistenceError, composerDraftsAtom, + composerDraftsLoadedAtom, composerCloudDraftsAtom, createNewTaskDraft, createComposerDraftContextHistory, @@ -1949,6 +1955,21 @@ describe("mobile composer drafts", () => { }); }); + it("retries a transient read failure while hydrating", async () => { + composerDraftFileMocks.setDocument({ + schemaVersion: 1, + drafts: { "environment-1:thread-1": DRAFT }, + }); + composerDraftFileMocks.setReadError(new Error("storage busy"), 1); + + await waitForComposerDraftsLoaded(); + + expect(appAtomRegistry.get(composerDraftsLoadedAtom)).toBe(true); + expect(appAtomRegistry.get(composerDraftsAtom)).toEqual({ + "environment-1:thread-1": DRAFT, + }); + }); + it("flush waits for pending hydration instead of clobbering disk", async () => { vi.useFakeTimers(); composerDraftFileMocks.setDocument({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 70c9503f34c27..cc09c4dc332b2 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -21,7 +21,6 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; -import { useEffect } from "react"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import * as Option from "effect/Option"; import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; @@ -472,9 +471,10 @@ export const composerDraftsAtom = Atom.make>({}).p ); /** - * Whether persisted drafts have merged in. Hydration starts with the app's - * attachment upload worker; a failed load stays false until the next - * `ensureComposerDraftsLoaded` caller retries it. + * Whether persisted drafts have merged in. Hydration starts at app start with + * the attachment upload worker (`installComposerAttachmentUploads`), so the + * read hooks below do not start it. A load that still fails after its retries + * stays false until the next draft write calls `ensureComposerDraftsLoaded`. */ export const composerDraftsLoadedAtom = Atom.make(false).pipe( Atom.keepAlive, @@ -743,6 +743,24 @@ async function loadPersistedComposerState(): Promise< } } +const HYDRATE_ATTEMPTS = 3; + +async function loadPersistedComposerStateWithRetry(): Promise< + ReturnType +> { + for (let attempt = 1; ; attempt += 1) { + try { + return await loadPersistedComposerState(); + } catch (cause) { + // Opening or reading the file can fail transiently; a document that does + // not decode fails the same way every time. + const decodeFailure = + cause instanceof ComposerDraftPersistenceError && cause.operation === "decode"; + if (decodeFailure || attempt >= HYDRATE_ATTEMPTS) throw cause; + } + } +} + async function writePersistedComposerState( drafts: Record, stickyModelSelection: ModelSelection | null, @@ -1023,7 +1041,7 @@ export function ensureComposerDraftsLoaded(): void { if (loadPromise !== null) { return; } - const loading = loadPersistedComposerState().then((persisted) => { + const loading = loadPersistedComposerStateWithRetry().then((persisted) => { appAtomRegistry.set(composerCloudDraftsAtom, persisted.cloudDrafts); if (Object.keys(persisted.drafts).length > 0) { const current = appAtomRegistry.get(composerDraftsAtom); @@ -2035,27 +2053,13 @@ const composerDraftSettingsAtom = Atom.family((draftKey: string) => const EMPTY_DRAFT_SETTINGS_ATOM = Atom.make(EMPTY_DRAFT_SETTINGS); export function useComposerDraftSettings(draftKey: string | null): ComposerDraftSettings { - const settings = useAtomValue( - draftKey ? composerDraftSettingsAtom(draftKey) : EMPTY_DRAFT_SETTINGS_ATOM, - ); - useEffect(() => { - ensureComposerDraftsLoaded(); - }, []); - return settings; + return useAtomValue(draftKey ? composerDraftSettingsAtom(draftKey) : EMPTY_DRAFT_SETTINGS_ATOM); } export function useComposerDraft(draftKey: string | null): ComposerDraft { - const draft = useAtomValue(draftKey ? composerDraftAtom(draftKey) : EMPTY_DRAFT_ATOM); - useEffect(() => { - ensureComposerDraftsLoaded(); - }, []); - return draft; + return useAtomValue(draftKey ? composerDraftAtom(draftKey) : EMPTY_DRAFT_ATOM); } export function useStickyComposerModelSelection(): ModelSelection | null { - const selection = useAtomValue(stickyComposerModelSelectionAtom); - useEffect(() => { - ensureComposerDraftsLoaded(); - }, []); - return selection; + return useAtomValue(stickyComposerModelSelectionAtom); }