Open History to see the last commit
1import * as Effect from "effect/Effect";
2import { AsyncResult, Atom } from "effect/reactivity";
3
4import * as MobilePreferences from "../persistence/mobile-preferences";
5import * as Runtime from "../lib/runtime";
6
7interface OptimisticPreferences {
8 readonly values: Partial<MobilePreferences.Preferences>;
9 readonly versions: Partial<Record<keyof MobilePreferences.Preferences, number>>;
10}
11
12// useAtomSet treats bare functions as AsyncResult updates; wrap preference transforms in payloads.
13type PreferencesUpdate =
14 | Partial<MobilePreferences.Preferences>
15 | {
16 readonly transform: (
17 current: MobilePreferences.Preferences,
18 ) => Partial<MobilePreferences.Preferences>;
19 };
20
21/** Keep optimistic patches separate so hydration cannot overwrite pending writes. */
22export function createMobilePreferencesState(
23 runtime: Atom.AtomRuntime<MobilePreferences.MobilePreferencesStore>,
24) {
25 const storedPreferencesAtom = runtime
26 .atom(
27 MobilePreferences.MobilePreferencesStore.pipe(
28 Effect.flatMap((store) => store.load),
29 Effect.catch((error) =>
30 Effect.logWarning("Could not load mobile preferences.", error).pipe(
31 Effect.as<MobilePreferences.Preferences>({}),
32 ),
33 ),
34 ),
35 )
36 .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:stored"));
37
38 const optimisticPatchAtom = Atom.make<OptimisticPreferences>({ values: {}, versions: {} }).pipe(
39 Atom.keepAlive,
40 Atom.withLabel("mobile:preferences:optimistic-patch"),
41 );
42 const confirmedPreferencesAtom = Atom.make<MobilePreferences.Preferences>({}).pipe(
43 Atom.keepAlive,
44 Atom.withLabel("mobile:preferences:confirmed"),
45 );
46 let nextPatchVersion = 0;
47
48 const preferencesAtom = Atom.make((get) => {
49 const stored = get(storedPreferencesAtom);
50 const confirmed = get(confirmedPreferencesAtom);
51 const optimistic = get(optimisticPatchAtom);
52 return AsyncResult.map(stored, (preferences) => ({
53 ...preferences,
54 ...confirmed,
55 ...optimistic.values,
56 }));
57 }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences"));
58
59 const updatePreferencesAtom = runtime
60 .fn(
61 (update: PreferencesUpdate, get) => {
62 const currentPreferences = get(preferencesAtom);
63 const patch =
64 "transform" in update
65 ? update.transform(
66 AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {},
67 )
68 : update;
69 const version = ++nextPatchVersion;
70 const current = get(optimisticPatchAtom);
71 const versions = { ...current.versions };
72 for (const key of Object.keys(patch) as Array<keyof MobilePreferences.Preferences>) {
73 versions[key] = version;
74 }
75 get.set(optimisticPatchAtom, {
76 values: { ...current.values, ...patch },
77 versions,
78 });
79 return MobilePreferences.MobilePreferencesStore.pipe(
80 Effect.flatMap((store) =>
81 "transform" in update ? store.update(update.transform) : store.savePatch(patch),
82 ),
83 Effect.tap((saved) =>
84 Effect.sync(() => {
85 get.set(confirmedPreferencesAtom, saved);
86 const optimistic = get(optimisticPatchAtom);
87 const values = { ...optimistic.values } as Record<string, unknown>;
88 const currentVersions = { ...optimistic.versions } as Record<string, unknown>;
89 for (const key of Object.keys(patch) as Array<keyof MobilePreferences.Preferences>) {
90 if (optimistic.versions[key] === version) {
91 delete values[key];
92 delete currentVersions[key];
93 }
94 }
95 get.set(optimisticPatchAtom, {
96 values: values as Partial<MobilePreferences.Preferences>,
97 versions: currentVersions as Partial<
98 Record<keyof MobilePreferences.Preferences, number>
99 >,
100 });
101 }),
102 ),
103 Effect.tapError(() =>
104 Effect.sync(() => {
105 const optimistic = get(optimisticPatchAtom);
106 const values = { ...optimistic.values } as Record<string, unknown>;
107 const currentVersions = { ...optimistic.versions } as Record<string, unknown>;
108 for (const key of Object.keys(patch) as Array<keyof MobilePreferences.Preferences>) {
109 if (optimistic.versions[key] === version) {
110 delete values[key];
111 delete currentVersions[key];
112 }
113 }
114 get.set(optimisticPatchAtom, {
115 values: values as Partial<MobilePreferences.Preferences>,
116 versions: currentVersions as Partial<
117 Record<keyof MobilePreferences.Preferences, number>
118 >,
119 });
120 }),
121 ),
122 );
123 },
124 // Storage serializes shared-blob updates; keep concurrent invocations alive to prevent cross-field interruption.
125 { concurrent: true },
126 )
127 .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:update"));
128
129 return { preferencesAtom, updatePreferencesAtom } as const;
130}
131
132const mobilePreferencesRuntime = Atom.runtime(Runtime.layer);
133const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime);
134
135export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom;
136export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom;