From 82cd1d1aabe8baa4a5ae23f3c7b08b3fce092e08 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 18 Sep 2026 14:23:09 -0700 Subject: [PATCH] fix(mobile): preserve multiple model favorites (#12505) --- .../features/threads/ThreadSettingsSheet.tsx | 15 +++--- apps/mobile/src/state/preferences.test.ts | 53 ++++++++++++++++--- apps/mobile/src/state/preferences.ts | 14 +++-- 3 files changed, 63 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 9dbefe6018b7683cc3f0d1a5a19a29be07a99f12..b8411229a8acfbb21aa3ceb5b25c256a01af4fa1 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -47,7 +47,6 @@ import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import type { Preferences } from "../../persistence/mobile-preferences"; import { NativeHeaderToolbar, NativeStackScreenOptions, @@ -331,12 +330,14 @@ function ThreadSettingsSessionProvider( (option: ModelOption) => { if (!favoritesLoaded) return; void Haptics.selectionAsync(); - savePreferences((current: Preferences) => ({ - modelFavorites: toggleModelFavorite( - current.modelFavorites ?? EMPTY_MODEL_FAVORITES, - option, - ), - })); + savePreferences({ + transform: (current) => ({ + modelFavorites: toggleModelFavorite( + current.modelFavorites ?? EMPTY_MODEL_FAVORITES, + option, + ), + }), + }); }, [favoritesLoaded, savePreferences], ); diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index 48b7badcc2c2958992643eae74ba1d877ddc5ba7..9a84d70cd61a816a8da2b68f45483b769724b69d 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -5,6 +5,9 @@ import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { vi } from "vite-plus/test"; import { ProviderInstanceId } from "@t3tools/contracts"; +import { RegistryContext, useAtomSet } from "@effect/atom-react"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(), @@ -125,12 +128,16 @@ describe("mobile preferences state", () => { }), ); - it.effect("keeps both favorites when updates are sent before a render", () => + it.effect("keeps both favorites when the React setter sends updates before a render", () => Effect.gen(function* () { let persisted: Preferences = { modelFavorites: [] }; const state = makePreferencesState({ load: Effect.succeed(persisted), - savePatch: () => Effect.die(new Error("Favorite updates must use a transform.")), + savePatch: (patch) => + Effect.sync(() => { + persisted = { ...persisted, ...patch }; + return persisted; + }), update: (transform) => Effect.sync(() => { persisted = { ...persisted, ...transform(persisted) }; @@ -142,13 +149,31 @@ describe("mobile preferences state", () => { const unmountUpdate = registry.mount(state.updatePreferencesAtom); yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true }); + function useSavePreferences() { + return useAtomSet(state.updatePreferencesAtom); + } + const setters: Array> = []; + function CaptureSetter() { + setters.push(useSavePreferences()); + return null; + } + // Exercise the real React setter, which treats bare functions as updates + // to the atom's read value. Direct registry.set calls bypass that behavior. + renderToString( + createElement(RegistryContext.Provider, { value: registry }, createElement(CaptureSetter)), + ); + const savePreferences = setters[0]!; const provider = ProviderInstanceId.make("codex"); - registry.set(state.updatePreferencesAtom, (current) => ({ - modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }], - })); - registry.set(state.updatePreferencesAtom, (current) => ({ - modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }], - })); + savePreferences({ + transform: (current) => ({ + modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }], + }), + }); + savePreferences({ + transform: (current) => ({ + modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }], + }), + }); yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, { suspendOnWaiting: true, }); @@ -158,6 +183,18 @@ describe("mobile preferences state", () => { { provider, model: "sol" }, ]); + savePreferences({ + transform: (current) => ({ + modelFavorites: (current.modelFavorites ?? []).filter( + (favorite) => favorite.model !== "astra", + ), + }), + }); + yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, { + suspendOnWaiting: true, + }); + expect(persisted.modelFavorites).toEqual([{ provider, model: "sol" }]); + unmountUpdate(); unmountPreferences(); registry.dispose(); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index bcecc69db45832b0d3c888a6c1c9fc999c88eb6b..7baff0251c15859a5454da7af893b68452c6911f 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -15,7 +15,11 @@ interface OptimisticPreferences { readonly versions: Partial>; } -type PreferencesUpdate = Partial | ((current: Preferences) => Partial); +// A bare function is interpreted by useAtomSet as an update to the command's +// AsyncResult, not as a preference transform. Keep transforms inside a payload. +type PreferencesUpdate = + | Partial + | { readonly transform: (current: Preferences) => Partial }; /** * Owns the device preference blob for the lifetime of the app registry. @@ -62,8 +66,10 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime { const currentPreferences = get(preferencesAtom); const patch = - typeof update === "function" - ? update(AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {}) + "transform" in update + ? update.transform( + AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {}, + ) : update; const version = ++nextPatchVersion; const current = get(optimisticPatchAtom); @@ -77,7 +83,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime - typeof update === "function" ? store.update(update) : store.savePatch(patch), + "transform" in update ? store.update(update.transform) : store.savePatch(patch), ), Effect.tap((saved) => Effect.sync(() => { -- 2.54.0