diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index f6f928a15fd05..d6d353a25b7b6 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => { const values = new Map(); @@ -98,20 +98,27 @@ vi.mock("react-native", () => ({ }, })); -import { loadPreferences, savePreferencesPatch } from "../persistence/imperative"; +let runtime: typeof import("./runtime").runtime; +let storage: typeof import("../persistence/imperative"); describe("mobile connection storage", () => { - beforeEach(() => { + // The preferences store caches per runtime, so each case gets a fresh one. + beforeEach(async () => { mocks.clear(); vi.clearAllMocks(); + vi.resetModules(); + ({ runtime } = await import("./runtime")); + storage = await import("../persistence/imperative"); }); + afterEach(() => runtime.dispose()); + it("persists tab order locally and retains it when another preference changes", async () => { const navigationTabOrder = ["usage", "threads", "pullRequests"]; const hiddenNavigationTabs = ["issues", "home"]; - await savePreferencesPatch({ navigationTabOrder, hiddenNavigationTabs }); - await savePreferencesPatch({ baseFontSize: 19 }); - await expect(loadPreferences()).resolves.toEqual({ + await storage.savePreferencesPatch({ navigationTabOrder, hiddenNavigationTabs }); + await storage.savePreferencesPatch({ baseFontSize: 19 }); + await expect(storage.loadPreferences()).resolves.toEqual({ navigationTabOrder, hiddenNavigationTabs, baseFontSize: 19, @@ -127,7 +134,7 @@ describe("mobile connection storage", () => { }), 1, ); - await expect(loadPreferences()).resolves.toEqual({ + await expect(storage.loadPreferences()).resolves.toEqual({ navigationTabOrder: ["usage", "threads"], hiddenNavigationTabs: ["home"], baseFontSize: 18, @@ -138,12 +145,14 @@ describe("mobile connection storage", () => { mocks.setDatabaseFailures(true, true); await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); - await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + await expect(storage.loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); }); it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); - await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); + await expect(storage.savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ + baseFontSize: 19, + }); const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as { readonly payload: string; readonly updatedAt: number; @@ -162,7 +171,7 @@ describe("mobile connection storage", () => { }), ); - await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 }); + await expect(storage.loadPreferences()).resolves.toEqual({ baseFontSize: 19 }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 }); expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); }); @@ -177,7 +186,7 @@ describe("mobile connection storage", () => { }), ); - await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + await expect(storage.loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); }); @@ -186,7 +195,7 @@ describe("mobile connection storage", () => { mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); - await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + await expect(storage.loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); }); }); diff --git a/apps/mobile/src/persistence/mobile-preferences.test.ts b/apps/mobile/src/persistence/mobile-preferences.test.ts new file mode 100644 index 0000000000000..a44369b0b227b --- /dev/null +++ b/apps/mobile/src/persistence/mobile-preferences.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; + +vi.mock("expo-secure-store", () => ({})); + +import * as MobileDatabase from "./mobile-database"; +import * as MobilePreferences from "./mobile-preferences"; +import * as MobileSecureStorage from "./mobile-secure-storage"; + +const FALLBACK_KEY = "t3code.preferences.fallback"; + +function makeStorage() { + let databaseLoads = 0; + let failDatabaseLoad = false; + let pendingSave: + | { started: PromiseWithResolvers; finish: PromiseWithResolvers } + | undefined; + let stored: Option.Option = Option.none(); + const secure = new Map(); + + const database = MobileDatabase.MobileDatabase.of({ + loadCache: () => Effect.succeedNone, + listCache: () => Effect.succeed([]), + saveCache: () => Effect.void, + removeCache: () => Effect.void, + clearCacheKind: () => Effect.void, + clearEnvironmentCache: () => Effect.void, + clearAllCaches: Effect.void, + inspectCaches: Effect.succeed([]), + loadPreferencesJson: Effect.suspend(() => { + databaseLoads++; + return failDatabaseLoad + ? Effect.fail( + new MobileDatabase.MobileDatabaseError({ operation: "load-preferences", cause: null }), + ) + : Effect.succeed(stored); + }), + savePreferencesJson: (payload, updatedAt) => + Effect.promise(async () => { + const pending = pendingSave; + pendingSave = undefined; + if (pending) { + pending.started.resolve(); + await pending.finish.promise; + } + stored = Option.some({ payload, updatedAt }); + }), + }); + const secureStorage = MobileSecureStorage.MobileSecureStorage.of({ + getItem: (key) => Effect.sync(() => secure.get(key) ?? null), + setItem: (key, value) => Effect.sync(() => void secure.set(key, value)), + removeItem: (key) => Effect.sync(() => void secure.delete(key)), + }); + + const layer = MobilePreferences.layer.pipe( + Layer.provide( + Layer.merge( + Layer.succeed(MobileDatabase.MobileDatabase, database), + Layer.succeed(MobileSecureStorage.MobileSecureStorage, secureStorage), + ), + ), + ); + return { + secure, + layer, + databaseLoads: () => databaseLoads, + failDatabaseLoad: (fail: boolean) => { + failDatabaseLoad = fail; + }, + delayNextSave: () => { + const started = Promise.withResolvers(); + const finish = Promise.withResolvers(); + pendingSave = { started, finish }; + return { started: Effect.promise(() => started.promise), finish: () => finish.resolve() }; + }, + stored: () => Option.map(stored, ({ payload }) => JSON.parse(payload) as unknown), + setStored: (preferences: MobilePreferences.Preferences, updatedAt: number) => { + stored = Option.some({ payload: JSON.stringify(preferences), updatedAt }); + }, + }; +} + +describe("MobilePreferencesStore", () => { + it.effect("reads storage once across serialized updates", () => { + const storage = makeStorage(); + return Effect.gen(function* () { + const store = yield* MobilePreferences.MobilePreferencesStore; + yield* Effect.all( + [ + store.savePatch({ mathRenderingEnabled: true }), + store.update((current) => ({ + collapsedProjectGroups: [...(current.collapsedProjectGroups ?? []), "a"], + })), + store.update((current) => ({ + collapsedProjectGroups: [...(current.collapsedProjectGroups ?? []), "b"], + })), + ], + { concurrency: "unbounded" }, + ); + + expect(storage.stored()).toEqual( + Option.some({ mathRenderingEnabled: true, collapsedProjectGroups: ["a", "b"] }), + ); + expect(storage.databaseLoads()).toBe(1); + }).pipe(Effect.provide(storage.layer)); + }); + + it.effect.each(["update", "fallback", "legacy"] as const)( + "waits for an interrupted %s write before the next update", + (path) => { + const storage = makeStorage(); + return Effect.gen(function* () { + const store = yield* MobilePreferences.MobilePreferencesStore; + const preferences = { mathRenderingEnabled: true, workingShelfEnabled: true }; + if (path === "update") { + yield* store.savePatch({ mathRenderingEnabled: true }); + } else if (path === "fallback") { + storage.secure.set( + FALLBACK_KEY, + JSON.stringify({ payload: JSON.stringify(preferences), updatedAt: 100 }), + ); + } else { + storage.secure.set("t3code.preferences", JSON.stringify(preferences)); + } + + const pending = storage.delayNextSave(); + const order: string[] = []; + const write: Effect.Effect< + MobilePreferences.Preferences, + | MobilePreferences.MobilePreferencesSaveError + | MobilePreferences.MobilePreferencesLoadError + > = path === "update" ? store.savePatch({ workingShelfEnabled: true }) : store.load; + const interrupted = yield* Effect.forkChild( + write.pipe(Effect.onExit(() => Effect.sync(() => void order.push("write exited")))), + ); + yield* pending.started; + const interruption = yield* Effect.forkChild(Fiber.interrupt(interrupted), { + startImmediately: true, + }); + const next = yield* Effect.forkChild( + store + .savePatch({ codeWordBreak: true }) + .pipe(Effect.onExit(() => Effect.sync(() => void order.push("next exited")))), + { startImmediately: true }, + ); + order.push("native write released"); + pending.finish(); + yield* Fiber.join(interruption); + yield* Fiber.join(next); + yield* store.savePatch({ collapsedProjectGroups: ["a"] }); + + expect(storage.stored()).toEqual( + Option.some({ ...preferences, codeWordBreak: true, collapsedProjectGroups: ["a"] }), + ); + expect(order).toEqual(["native write released", "write exited", "next exited"]); + }).pipe(Effect.provide(storage.layer)); + }, + ); + + it.effect("does not reuse a load that could not read the database", () => { + const storage = makeStorage(); + storage.setStored({ mathRenderingEnabled: true }, 100); + storage.secure.set( + FALLBACK_KEY, + JSON.stringify({ payload: JSON.stringify({ mathRenderingEnabled: false }), updatedAt: 50 }), + ); + return Effect.gen(function* () { + const store = yield* MobilePreferences.MobilePreferencesStore; + storage.failDatabaseLoad(true); + expect(yield* store.load).toEqual({ mathRenderingEnabled: false }); + + storage.failDatabaseLoad(false); + yield* store.savePatch({ codeWordBreak: true }); + expect(storage.stored()).toEqual( + Option.some({ mathRenderingEnabled: true, codeWordBreak: true }), + ); + }).pipe(Effect.provide(storage.layer)); + }); +}); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 852d96c608520..e930e9d7c4ff0 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -377,6 +377,8 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; const lock = yield* Semaphore.make(1); const lastUpdatedAt = yield* Ref.make(0); + // This store is the only writer, so a complete load or a save stays current until an update fails. + const cachedPreferences = yield* Ref.make(undefined); const parsePayload = (raw: string | null): Preferences | null => { if (raw === null || !raw.trim()) return null; @@ -461,7 +463,7 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { ), ), ); - }); + }, Effect.uninterruptible); const loadUnlocked = Effect.gen(function* () { const databaseResult = yield* Effect.result(database.loadPreferencesJson); @@ -535,7 +537,10 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { } } - return parsed === null ? {} : sanitizePreferences(parsed); + const preferences = parsed === null ? {} : sanitizePreferences(parsed); + if (databaseAvailable && fallbackResult._tag === "Success") + yield* Ref.set(cachedPreferences, preferences); + return preferences; }); const load = lock @@ -547,7 +552,7 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { lock .withPermits(1)( Effect.gen(function* () { - const current = yield* loadUnlocked; + const current = (yield* Ref.get(cachedPreferences)) ?? (yield* loadUnlocked); const patch = yield* Effect.try({ try: () => transform(current), catch: (cause) => new MobilePreferencesSaveError({ cause }), @@ -555,8 +560,9 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { const next: Preferences = { ...current, ...patch }; const payload = yield* encode(PREFERENCES_KEY, next); yield* saveJson(payload); + yield* Ref.set(cachedPreferences, sanitizePreferences(parsePayload(payload) ?? {})); return next; - }), + }).pipe(Effect.onError(() => Ref.set(cachedPreferences, undefined))), ) .pipe( Effect.mapError((cause) =>