From bc48fe78c354e4d7268b0a4be3c69d9d899ad9ae Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 16:40:32 +0000 Subject: [PATCH 1/4] perf(mobile): skip the storage reload before each preference save The preferences store is the only writer of its SQLite row and keychain fallback, so a complete load or a successful save is reused by the next update. A failed or interrupted update, or a load that could not read the database or the fallback, still reads storage again. Co-Authored-By: Claude Opus 5.5 --- .../persistence/mobile-preferences.test.ts | 142 ++++++++++++++++++ .../src/persistence/mobile-preferences.ts | 12 +- 2 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/persistence/mobile-preferences.test.ts 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..1a1a98e189f89 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-preferences.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +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 afterDatabaseSave: Effect.Effect = Effect.void; + let stored: Option.Option = Option.none(); + const secure = new Map(); + + const database = MobileDatabase.MobileDatabase.of({ + loadCache: () => Effect.succeed(Option.none()), + 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.suspend(() => { + stored = Option.some({ payload, updatedAt }); + return afterDatabaseSave; + }), + }); + 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; + }, + afterDatabaseSave: (effect: Effect.Effect) => { + afterDatabaseSave = effect; + }, + 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({ planModeEnabled: true }), + store.update((current) => ({ + collapsedProjectGroups: [...(current.collapsedProjectGroups ?? []), "a"], + })), + store.update((current) => ({ + collapsedProjectGroups: [...(current.collapsedProjectGroups ?? []), "b"], + })), + ], + { concurrency: "unbounded" }, + ); + + expect(storage.stored()).toEqual( + Option.some({ planModeEnabled: true, collapsedProjectGroups: ["a", "b"] }), + ); + expect(storage.databaseLoads()).toBe(1); + }).pipe(Effect.provide(storage.layer)); + }); + + it.effect("rereads storage after an update is interrupted mid-save", () => { + const storage = makeStorage(); + return Effect.gen(function* () { + const store = yield* MobilePreferences.MobilePreferencesStore; + yield* store.savePatch({ planModeEnabled: true }); + + const saved = yield* Deferred.make(); + storage.afterDatabaseSave( + Deferred.succeed(saved, undefined).pipe(Effect.andThen(Effect.never)), + ); + const interrupted = yield* Effect.forkChild(store.savePatch({ workingShelfEnabled: true })); + yield* Deferred.await(saved); + yield* Fiber.interrupt(interrupted); + + storage.afterDatabaseSave(Effect.void); + yield* store.savePatch({ codeWordBreak: true }); + expect(storage.stored()).toEqual( + Option.some({ planModeEnabled: true, workingShelfEnabled: true, codeWordBreak: true }), + ); + }).pipe(Effect.provide(storage.layer)); + }); + + it.effect("does not reuse a load that could not read the database", () => { + const storage = makeStorage(); + storage.setStored({ planModeEnabled: true }, 100); + storage.secure.set( + FALLBACK_KEY, + JSON.stringify({ payload: JSON.stringify({ planModeEnabled: false }), updatedAt: 50 }), + ); + return Effect.gen(function* () { + const store = yield* MobilePreferences.MobilePreferencesStore; + storage.failDatabaseLoad(true); + expect(yield* store.load).toEqual({ planModeEnabled: false }); + + storage.failDatabaseLoad(false); + yield* store.savePatch({ codeWordBreak: true }); + expect(storage.stored()).toEqual(Option.some({ planModeEnabled: 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 43ac75e185411..5118ddda203e8 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -367,6 +367,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; @@ -526,7 +528,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 @@ -537,7 +542,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 }), @@ -545,8 +550,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) => From 2225784822579699ca118bb42cf5a11dc9283fe3 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 17:15:55 +0000 Subject: [PATCH 2/4] test(mobile): give each storage test case a fresh preferences store The preferences store now keeps what it last read or wrote, and these cases reset the storage mocks underneath one shared runtime. A fresh module import per case keeps them independent. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/storage.test.ts | 33 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) 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 }); }); }); From cad372c909922643003f3925d6f5ca0c3ffd87e2 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 18:50:54 +0000 Subject: [PATCH 3/4] fix(mobile): keep preference writes serialized through cancellation --- .../persistence/mobile-preferences.test.ts | 89 +++++++++++++------ .../src/persistence/mobile-preferences.ts | 2 +- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/persistence/mobile-preferences.test.ts b/apps/mobile/src/persistence/mobile-preferences.test.ts index 1a1a98e189f89..a74669a5173f7 100644 --- a/apps/mobile/src/persistence/mobile-preferences.test.ts +++ b/apps/mobile/src/persistence/mobile-preferences.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from "@effect/vitest"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -17,7 +16,9 @@ const FALLBACK_KEY = "t3code.preferences.fallback"; function makeStorage() { let databaseLoads = 0; let failDatabaseLoad = false; - let afterDatabaseSave: Effect.Effect = Effect.void; + let pendingSave: + | { started: PromiseWithResolvers; finish: PromiseWithResolvers } + | undefined; let stored: Option.Option = Option.none(); const secure = new Map(); @@ -39,9 +40,14 @@ function makeStorage() { : Effect.succeed(stored); }), savePreferencesJson: (payload, updatedAt) => - Effect.suspend(() => { + Effect.promise(async () => { + const pending = pendingSave; + pendingSave = undefined; + if (pending) { + pending.started.resolve(); + await pending.finish.promise; + } stored = Option.some({ payload, updatedAt }); - return afterDatabaseSave; }), }); const secureStorage = MobileSecureStorage.MobileSecureStorage.of({ @@ -65,8 +71,11 @@ function makeStorage() { failDatabaseLoad: (fail: boolean) => { failDatabaseLoad = fail; }, - afterDatabaseSave: (effect: Effect.Effect) => { - afterDatabaseSave = effect; + 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) => { @@ -100,27 +109,57 @@ describe("MobilePreferencesStore", () => { }).pipe(Effect.provide(storage.layer)); }); - it.effect("rereads storage after an update is interrupted mid-save", () => { - const storage = makeStorage(); - return Effect.gen(function* () { - const store = yield* MobilePreferences.MobilePreferencesStore; - yield* store.savePatch({ planModeEnabled: true }); + 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 = { planModeEnabled: true, workingShelfEnabled: true }; + if (path === "update") { + yield* store.savePatch({ planModeEnabled: 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 saved = yield* Deferred.make(); - storage.afterDatabaseSave( - Deferred.succeed(saved, undefined).pipe(Effect.andThen(Effect.never)), - ); - const interrupted = yield* Effect.forkChild(store.savePatch({ workingShelfEnabled: true })); - yield* Deferred.await(saved); - yield* Fiber.interrupt(interrupted); + 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"] }); - storage.afterDatabaseSave(Effect.void); - yield* store.savePatch({ codeWordBreak: true }); - expect(storage.stored()).toEqual( - Option.some({ planModeEnabled: true, workingShelfEnabled: true, codeWordBreak: true }), - ); - }).pipe(Effect.provide(storage.layer)); - }); + 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(); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 5118ddda203e8..cdd140e4e469a 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -454,7 +454,7 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { ), ), ); - }); + }, Effect.uninterruptible); const loadUnlocked = Effect.gen(function* () { const databaseResult = yield* Effect.result(database.loadPreferencesJson); From d263211cd3a07fe3e4cf2ef5c8b65984c5b5a180 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 04:32:03 +0000 Subject: [PATCH 4/4] fix(mobile): use Effect.succeedNone in preference store test --- apps/mobile/src/persistence/mobile-preferences.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/persistence/mobile-preferences.test.ts b/apps/mobile/src/persistence/mobile-preferences.test.ts index a74669a5173f7..7b4dd4ad3c5d3 100644 --- a/apps/mobile/src/persistence/mobile-preferences.test.ts +++ b/apps/mobile/src/persistence/mobile-preferences.test.ts @@ -23,7 +23,7 @@ function makeStorage() { const secure = new Map(); const database = MobileDatabase.MobileDatabase.of({ - loadCache: () => Effect.succeed(Option.none()), + loadCache: () => Effect.succeedNone, listCache: () => Effect.succeed([]), saveCache: () => Effect.void, removeCache: () => Effect.void,