Repository navigation
Expand file tree
/
Copy pathuseProfileSetting.ts
More file actions
350 lines (334 loc) Β· 15.5 KB
/
Copy pathuseProfileSetting.ts
File metadata and controls
350 lines (334 loc) Β· 15.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
import { useCallback, useEffect, useRef, useState } from "react";
import { getProfileSetting, setProfileSetting } from "../lib/tauri/profile";
import { useProfile } from "./useProfile";
import {
PROFILE_SETTING_CHANGED,
profileSettingBridgeReady,
startProfileSettingBridge,
} from "../lib/profileSettingBridge";
/**
* One implementation of the per-profile preference pattern, shared by
* every `profile_setting`-backed hook (issue #485).
*
* It used to be copy-pasted per preference, and the copies drifted:
* some had a readiness flag, some rolled back to the optimistic value
* instead of the confirmed one, only one invalidated a stale read. The
* guarantees below are the union of the best of each, so a new
* preference gets all of them for free:
*
* - **Optimistic UI.** `setValue` updates the render state and a
* synchronous ref immediately; persistence happens behind it.
* - **Serialized writes.** Each write waits for the previous one, so
* two rapid toggles land in click order and the last user action wins
* the final DB state.
* - **Cross-invalidation.** Reads and writes share one monotonic token.
* A read applies its result only while it's still the newest thing
* that happened, so a read in flight can't clobber a toggle fired
* meanwhile β and only the newest write broadcasts.
* - **Rollback to confirmed truth.** A failed write reverts to the last
* value the backend acknowledged, not to the pre-toggle optimistic
* value, which may itself never have been persisted after a run of
* failures. It only reverts when nothing newer replaced the value it
* installed (identity comparison).
* - **Profile isolation.** A switch resets the value to the default and
* drops `ready` before reading the new profile, so the outgoing
* profile's preference never paints β even if that read then fails.
* Both IPC calls carry the captured profile id, so the backend
* refuses a queued write whose profile changed under it instead of
* writing it into the new one (a JS-side check can't cover that gap;
* it runs before the IPC hop).
* - **Cross-consumer sync.** A successful write broadcasts `event`, and
* every mounted instance re-reads.
*
* `parse`, `serialize` and `defaultValue` must be stable across renders
* β declare them at module level. They're held in refs, so changing
* their identity mid-life is ignored rather than re-running the read.
*/
export interface ProfileSettingOptions<T> {
/** `profile_setting` key, e.g. `"ui.artist_hero"`. */
key: string;
/** Value used before the first read lands, and after a profile switch. */
defaultValue: T;
/** Turn the stored string (or `null` when unset) into a value. */
parse: (raw: string | null) => T;
/** Turn a value into the string to persist. */
serialize: (value: T) => string;
/** Typed marker stored alongside the raw string. */
valueType: "bool" | "int" | "string" | "json";
/** Window event broadcast after a successful write. */
event: string;
/** Prefix for console diagnostics, e.g. `"useArtistHero"`. */
label: string;
}
export interface ProfileSetting<T> {
value: T;
/**
* `false` until the active profile's value has been read. Consumers
* whose default would paint something (a backdrop, a hidden card)
* gate on this so nothing flashes before the preference loads.
*/
ready: boolean;
/**
* Optimistic update. Accepts a value or an updater, which receives the
* synchronously-current value so back-to-back calls each build on the
* previous one rather than on a render-lagged snapshot.
*
* Never rejects: failures are logged and rolled back internally.
*/
setValue: (next: T | ((previous: T) => T)) => Promise<void>;
/** Bumped every time a read lands, whatever it read. A consumer whose
* value has effects outside React state -- a document attribute, a
* cache the next launch reads -- keys on this as well as on `value`,
* because the case that needs re-applying is exactly the one where
* the value did not change: a rollback broadcast reaches an instance
* that never saw the optimistic value, so its own state is already
* correct while the document is not. */
revision: number;
}
/** Parse a persisted boolean. Anything other than the two truthy forms
* reads as false; a missing key falls back to the caller's default. */
export function parseBoolean(raw: string | null, fallback: boolean): boolean {
if (raw == null) return fallback;
return raw === "true" || raw === "1";
}
/**
* [`useProfileSetting`] specialised for the on/off preferences that make
* up most of Settings β Appearance. Same guarantees, minus the
* parse/serialize boilerplate every boolean was repeating.
*/
export function useProfileBooleanSetting(options: {
key: string;
defaultValue: boolean;
event: string;
label: string;
}): ProfileSetting<boolean> {
const { key, defaultValue, event, label } = options;
return useProfileSetting<boolean>({
key,
defaultValue,
// Defined inline but read through a ref by the hook, so the fresh
// identity on each render is harmless β see the options contract.
parse: (raw) => parseBoolean(raw, defaultValue),
serialize: (value) => (value ? "true" : "false"),
valueType: "bool",
event,
label,
});
}
export function useProfileSetting<T>(
options: ProfileSettingOptions<T>,
): ProfileSetting<T> {
// `label`, `parse`, `serialize` and `defaultValue` are read through
// `optionsRef` instead β they must not enter a dependency array.
const { key, valueType, event } = options;
const { activeProfile } = useProfile();
const activeProfileId = activeProfile?.id ?? null;
// Stable-by-contract inputs, held in refs so their identity never
// feeds a dependency array (a caller passing an inline arrow would
// otherwise re-run the read on every render).
const optionsRef = useRef(options);
// Refreshed after every render rather than during it (refs are not
// render-time state). Declared before the read effect below so that
// effect always sees the latest β and by contract these values don't
// change anyway.
useEffect(() => {
optionsRef.current = options;
});
const [value, setValueState] = useState<T>(options.defaultValue);
const [ready, setReady] = useState(false);
// Authoritative copy read synchronously by `setValue` β React state
// lags a render behind, so two rapid updates would otherwise both
// branch off the same stale snapshot.
const valueRef = useRef<T>(options.defaultValue);
const commit = useCallback((next: T) => {
valueRef.current = next;
setValueState(next);
}, []);
// See `revision` on the returned shape: a counter, not a value, so
// that a read landing on an unchanged value still wakes the consumers
// whose side effects live outside React state.
const [revision, setRevision] = useState(0);
// Last value the backend acknowledged β the rollback target.
const confirmedRef = useRef<T>(options.defaultValue);
const writeChainRef = useRef<Promise<unknown>>(Promise.resolve());
// Who owns the value: bumped by every read AND every write, so
// whoever acted last wins and stale results drop themselves.
const ownerSeqRef = useRef(0);
// Who broadcasts: bumped by writes only. Kept separate from
// `ownerSeqRef` because a read landing mid-write must invalidate that
// write's *value*, not silence its broadcast β other consumers still
// need to hear about it.
const writeSeqRef = useRef(0);
const activeProfileIdRef = useRef<number | null>(activeProfileId);
useEffect(() => {
activeProfileIdRef.current = activeProfileId;
}, [activeProfileId]);
useEffect(() => {
let cancelled = false;
const { defaultValue, parse } = optionsRef.current;
// Start each profile from a clean slate so the outgoing profile's
// value can't paint while the new read is in flight β nor stay if
// that read fails.
/* eslint-disable react-hooks/set-state-in-effect */
setReady(false);
commit(defaultValue);
/* eslint-enable react-hooks/set-state-in-effect */
confirmedRef.current = defaultValue;
const refresh = async () => {
// A read does NOT take the token on the way in; it notes what the
// token was and claims it only when it has an answer to commit.
//
// Taking it up front and handing it back on failure -- which is
// what this did -- moves the counter backwards, and everything
// here relies on it only ever going up. Two reads in flight, the
// newer one failing and rewinding, left the token reading exactly
// what the OLDER one had claimed: that one then passed the
// ownership test and committed a value it had read before the
// newer read was even sent.
//
// Claiming late costs nothing, because a read has nothing to
// protect until it commits: a write claims up front instead, and
// must, since it paints its value immediately.
const startedAt = ownerSeqRef.current;
let relevant = false;
try {
const raw = await getProfileSetting(key, activeProfileId);
// Stale: a write (or a newer read) took the token while we
// awaited and owns the value now.
if (cancelled || ownerSeqRef.current !== startedAt) return;
relevant = true;
ownerSeqRef.current += 1;
const parsed = parse(raw);
commit(parsed);
confirmedRef.current = parsed;
} catch (err) {
console.error(`[${optionsRef.current.label}] read failed`, err);
// Nothing to hand back -- this read never took the token. It
// still gets to finalise if nobody claimed one while it was
// away, which is what "still the read that matters" means here.
// And because it took nothing, a write in flight keeps the
// token it claimed, so its rollback is still recognised as
// wanted rather than skipped over an optimistic value the
// database had refused.
relevant = ownerSeqRef.current === startedAt;
} finally {
// Ready on any OUTCOME -- a read that failed leaves the
// default in place, and never flipping this would gate the
// consumer forever -- but only from the read that still owns
// the value.
//
// Owning is not the same as having committed, which is what an
// earlier version of this comment got wrong. Between the
// effect's `commit(defaultValue)` and the newest read landing,
// `value` is the default; a stale read announcing ready in that
// window hands the consumer the default as though it were the
// stored answer. For `useContrastMode` that is the flash of
// ordinary contrast the gate exists to prevent, written over
// what the bootstrap had already stamped correctly.
//
// Nothing is gated forever by this: whatever took ownership
// either lands its own read, or is a write, and every write
// broadcasts -- on success and, since the rollback path does it
// too, on failure -- which brings a fresh read that flips it.
//
// `revision` follows the same gate, and matters for a reason
// that is not symmetry: the read this counter serves is the one
// a rollback broadcasts, and a write and the read behind it
// fail for the same reasons -- a closed pool, a profile going
// away. Bumping only on success would leave the case it exists
// for broken exactly when it is most likely. On a failed read
// the consumer re-applies its last confirmed value, which is
// the right one to put back.
if (!cancelled && relevant) {
setReady(true);
setRevision((r) => r + 1);
}
}
};
void refresh();
window.addEventListener(event, refresh);
// The same re-read, asked for by another webview. `event` is a
// `window` event and `window` is per document, so without this the
// mini-player and the lyrics overlay never hear about a preference
// changed in the main window (issue #741). The bridge is idempotent.
const onOtherWindow = (e: Event) => {
const detail = (e as CustomEvent<{ key?: string }>).detail;
if (detail?.key === key) void refresh();
};
window.addEventListener(PROFILE_SETTING_CHANGED, onOtherWindow);
// Registering the listener is itself asynchronous, so a write that
// lands while a window is still opening would be missed. Read once
// more when it goes live β only the mounts that raced it pay for it,
// and the first read above is not delayed behind it.
const wasReady = profileSettingBridgeReady();
void startProfileSettingBridge().then(() => {
if (!wasReady && !cancelled) void refresh();
});
return () => {
cancelled = true;
window.removeEventListener(event, refresh);
window.removeEventListener(PROFILE_SETTING_CHANGED, onOtherWindow);
};
}, [activeProfileId, key, event, commit]);
const setValue = useCallback(
async (next: T | ((previous: T) => T)) => {
const resolved =
typeof next === "function"
? (next as (previous: T) => T)(valueRef.current)
: next;
commit(resolved);
const profileAtWrite = activeProfileIdRef.current;
const ownerSeq = ++ownerSeqRef.current;
const writeSeq = ++writeSeqRef.current;
const write = writeChainRef.current
// Leading no-op catch: a prior failure must not break the chain
// for later writes.
.catch(() => {})
.then(async () => {
await setProfileSetting(
key,
optionsRef.current.serialize(resolved),
valueType,
// The backend re-checks this under the switch lock; the JS
// guard below is only an early out that saves an IPC hop.
profileAtWrite,
);
if (activeProfileIdRef.current !== profileAtWrite) return;
confirmedRef.current = resolved;
// Only the newest write broadcasts β an older one completing
// late must not make everyone re-read over a newer state.
if (writeSeq === writeSeqRef.current) {
window.dispatchEvent(new CustomEvent(event));
}
});
writeChainRef.current = write;
try {
await write;
} catch (err) {
console.error(`[${optionsRef.current.label}] write failed`, err);
if (activeProfileIdRef.current !== profileAtWrite) return;
// Roll back only when nothing newer took ownership. Comparing
// the token rather than the value itself is what makes this work
// for primitives, where two writes of `true` are indistinguishable.
if (ownerSeq === ownerSeqRef.current) {
commit(confirmedRef.current);
// Broadcast the rollback too, not just the success. Other
// mounted consumers of this key were never told about the
// optimistic value, so they cannot undo it on their own --
// and the instance that *can* may be gone: a settings card
// that started a write and was unmounted when its panel
// closed never runs its own rollback, leaving whatever side
// effect it painted (a document attribute, a cached choice)
// describing a value the database refused. Guarded like the
// success path, so a stale failure cannot pull everyone back
// over a newer write.
if (writeSeq === writeSeqRef.current) {
window.dispatchEvent(new CustomEvent(event));
}
}
}
},
[commit, key, valueType, event],
);
return { value, ready, revision, setValue };
}