1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
6import { vi } from "vite-plus/test";
7import { ProviderInstanceId } from "@t3tools/contracts";
8import { RegistryContext, useAtomSet } from "@effect/atom-react";
9import { createElement } from "react";
10import { renderToString } from "react-dom/server";
11
12vi.mock("expo-secure-store", () => ({
13 getItemAsync: vi.fn(),
14 setItemAsync: vi.fn(),
15}));
16
17vi.mock("react-native", () => ({
18 Platform: { OS: "ios" },
19}));
20
21vi.mock("../lib/runtime", async () => {
22 const Layer = await import("effect/Layer");
23 return {
24 runtime: { runPromise: vi.fn() },
25 layer: Layer.empty,
26 };
27});
28
29import { createMobilePreferencesState } from "./preferences";
30import * as MobilePreferences from "../persistence/mobile-preferences";
31
32function deferred<A>() {
33 let resolve!: (value: A) => void;
34 const promise = new Promise<A>((resume) => {
35 resolve = resume;
36 });
37 return { promise, resolve } as const;
38}
39
40function makePreferencesState(
41 service: Omit<MobilePreferences.MobilePreferencesStore["Service"], "update"> &
42 Partial<Pick<MobilePreferences.MobilePreferencesStore["Service"], "update">>,
43) {
44 const completeService = MobilePreferences.MobilePreferencesStore.of({
45 ...service,
46 update:
47 service.update ??
48 ((transform) =>
49 service.load.pipe(
50 Effect.flatMap((current) => service.savePatch(transform(current))),
51 Effect.mapError((cause) =>
52 cause._tag === "MobilePreferencesSaveError"
53 ? cause
54 : new MobilePreferences.MobilePreferencesSaveError({ cause }),
55 ),
56 )),
57 });
58 return createMobilePreferencesState(
59 Atom.runtime(Layer.succeed(MobilePreferences.MobilePreferencesStore, completeService)),
60 );
61}
62
63describe("mobile preferences state", () => {
64 it.effect("shares one preference load across consumers", () =>
65 Effect.gen(function* () {
66 const load = vi.fn(() =>
67 Promise.resolve<MobilePreferences.Preferences>({ baseFontSize: 17 }),
68 );
69 const state = makePreferencesState({
70 load: Effect.promise(load),
71 savePatch: (patch) => Effect.succeed(patch),
72 });
73 const registry = AtomRegistry.make();
74 const unmountFirst = registry.mount(state.preferencesAtom);
75 const unmountSecond = registry.mount(state.preferencesAtom);
76
77 expect(
78 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
79 suspendOnWaiting: true,
80 }),
81 ).toEqual({ baseFontSize: 17 });
82 expect(load).toHaveBeenCalledTimes(1);
83
84 unmountSecond();
85 unmountFirst();
86 registry.dispose();
87 }),
88 );
89
90 it.effect("preserves an optimistic patch when the initial load finishes later", () =>
91 Effect.gen(function* () {
92 const pendingLoad = deferred<MobilePreferences.Preferences>();
93 const savePatch = vi.fn((patch: Partial<MobilePreferences.Preferences>) =>
94 Effect.succeed(patch),
95 );
96 const state = makePreferencesState({
97 load: Effect.promise(() => pendingLoad.promise),
98 savePatch,
99 });
100 const registry = AtomRegistry.make();
101 const unmountPreferences = registry.mount(state.preferencesAtom);
102 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
103
104 registry.set(state.updatePreferencesAtom, {
105 collapsedProjectGroups: ["project:new"],
106 });
107 pendingLoad.resolve({
108 baseFontSize: 18,
109 collapsedProjectGroups: ["project:old"],
110 });
111
112 const preferences = yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
113 suspendOnWaiting: true,
114 });
115 expect(preferences).toEqual({
116 baseFontSize: 18,
117 collapsedProjectGroups: ["project:new"],
118 });
119 expect(savePatch).toHaveBeenCalledWith({
120 collapsedProjectGroups: ["project:new"],
121 });
122 expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false);
123
124 unmountUpdate();
125 unmountPreferences();
126 registry.dispose();
127 }),
128 );
129
130 it.effect("keeps both favorites when the React setter sends updates before a render", () =>
131 Effect.gen(function* () {
132 let persisted: MobilePreferences.Preferences = { modelFavorites: [] };
133 const state = makePreferencesState({
134 load: Effect.succeed(persisted),
135 savePatch: (patch) =>
136 Effect.sync(() => {
137 persisted = { ...persisted, ...patch };
138 return persisted;
139 }),
140 update: (transform) =>
141 Effect.sync(() => {
142 persisted = { ...persisted, ...transform(persisted) };
143 return persisted;
144 }),
145 });
146 const registry = AtomRegistry.make();
147 const unmountPreferences = registry.mount(state.preferencesAtom);
148 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
149 yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true });
150
151 function useSavePreferences() {
152 return useAtomSet(state.updatePreferencesAtom);
153 }
154 const setters: Array<ReturnType<typeof useSavePreferences>> = [];
155 function CaptureSetter() {
156 setters.push(useSavePreferences());
157 return null;
158 }
159 // Exercise the real React setter, which treats bare functions as updates
160 // to the atom's read value. Direct registry.set calls bypass that behavior.
161 renderToString(
162 createElement(RegistryContext.Provider, { value: registry }, createElement(CaptureSetter)),
163 );
164 const savePreferences = setters[0]!;
165 const provider = ProviderInstanceId.make("codex");
166 savePreferences({
167 transform: (current) => ({
168 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }],
169 }),
170 });
171 savePreferences({
172 transform: (current) => ({
173 modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }],
174 }),
175 });
176 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
177 suspendOnWaiting: true,
178 });
179
180 expect(persisted.modelFavorites).toEqual([
181 { provider, model: "astra" },
182 { provider, model: "sol" },
183 ]);
184
185 savePreferences({
186 transform: (current) => ({
187 modelFavorites: (current.modelFavorites ?? []).filter(
188 (favorite) => favorite.model !== "astra",
189 ),
190 }),
191 });
192 yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, {
193 suspendOnWaiting: true,
194 });
195 expect(persisted.modelFavorites).toEqual([{ provider, model: "sol" }]);
196
197 unmountUpdate();
198 unmountPreferences();
199 registry.dispose();
200 }),
201 );
202
203 it.effect("falls back to empty preferences when secure storage cannot be read", () =>
204 Effect.gen(function* () {
205 const state = makePreferencesState({
206 load: Effect.fail(
207 new MobilePreferences.MobilePreferencesLoadError({
208 cause: new Error("secure storage unavailable"),
209 }),
210 ),
211 savePatch: (patch) => Effect.succeed(patch),
212 });
213 const registry = AtomRegistry.make();
214 const unmount = registry.mount(state.preferencesAtom);
215
216 expect(
217 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
218 suspendOnWaiting: true,
219 }),
220 ).toEqual({});
221
222 unmount();
223 registry.dispose();
224 }),
225 );
226
227 it.effect("does not roll back a newer optimistic write with the same value", () =>
228 Effect.gen(function* () {
229 let saveCount = 0;
230 const state = makePreferencesState({
231 load: Effect.succeed({ baseFontSize: 16, codeFontSize: 13 }),
232 savePatch: (patch) => {
233 saveCount += 1;
234 return saveCount === 1
235 ? Effect.fail(
236 new MobilePreferences.MobilePreferencesSaveError({
237 cause: new Error("write failed"),
238 }),
239 )
240 : Effect.succeed(patch);
241 },
242 });
243 const registry = AtomRegistry.make();
244 const unmountPreferences = registry.mount(state.preferencesAtom);
245 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
246
247 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
248 suspendOnWaiting: true,
249 });
250 registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });
251 registry.set(state.updatePreferencesAtom, { baseFontSize: 18, codeFontSize: 15 });
252
253 yield* Effect.promise(() =>
254 vi.waitFor(() => {
255 expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false);
256 expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(
257 {
258 baseFontSize: 18,
259 codeFontSize: 15,
260 },
261 );
262 }),
263 );
264
265 unmountUpdate();
266 unmountPreferences();
267 registry.dispose();
268 }),
269 );
270
271 it.effect("rolls back an optimistic field when its save fails", () =>
272 Effect.gen(function* () {
273 const state = makePreferencesState({
274 load: Effect.succeed({ baseFontSize: 16 }),
275 savePatch: () =>
276 Effect.fail(
277 new MobilePreferences.MobilePreferencesSaveError({ cause: new Error("write failed") }),
278 ),
279 });
280 const registry = AtomRegistry.make();
281 const unmountPreferences = registry.mount(state.preferencesAtom);
282 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
283
284 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
285 suspendOnWaiting: true,
286 });
287 registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });
288
289 yield* Effect.promise(() =>
290 vi.waitFor(() => {
291 expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(
292 {
293 baseFontSize: 16,
294 },
295 );
296 }),
297 );
298
299 unmountUpdate();
300 unmountPreferences();
301 registry.dispose();
302 }),
303 );
304
305 it.effect("rolls back to the last confirmed value after a later save fails", () =>
306 Effect.gen(function* () {
307 let saveCount = 0;
308 const state = makePreferencesState({
309 load: Effect.succeed({ baseFontSize: 16 }),
310 savePatch: () => {
311 saveCount += 1;
312 return saveCount === 1
313 ? Effect.succeed({ baseFontSize: 14 })
314 : Effect.fail(
315 new MobilePreferences.MobilePreferencesSaveError({
316 cause: new Error("write failed"),
317 }),
318 );
319 },
320 });
321 const registry = AtomRegistry.make();
322 const unmountPreferences = registry.mount(state.preferencesAtom);
323 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
324
325 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
326 suspendOnWaiting: true,
327 });
328 registry.set(state.updatePreferencesAtom, { baseFontSize: 14 });
329 yield* Effect.promise(() =>
330 vi.waitFor(() => {
331 expect(saveCount).toBe(1);
332 expect(registry.get(state.updatePreferencesAtom).waiting).toBe(false);
333 expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(
334 {
335 baseFontSize: 14,
336 },
337 );
338 }),
339 );
340
341 registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });
342 yield* Effect.promise(() =>
343 vi.waitFor(() => {
344 expect(saveCount).toBe(2);
345 expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(
346 {
347 baseFontSize: 14,
348 },
349 );
350 }),
351 );
352
353 unmountUpdate();
354 unmountPreferences();
355 registry.dispose();
356 }),
357 );
358});