refactor(frontend): every stored key declares its kind, and preferences follow the account

storage.ts's registry gives each key a kind (pref, device, view, cache,
public); a key without one does not compile. A contract test refuses a key
spelled out elsewhere, an account preference read or written straight from
storage instead of through usePref, and an e2e seed of a key nobody reads.
The keys the next account's wipe keeps are the public ones.

The seven preferences kept only in the browser move onto usePref: start on
Home, the onboarding dismissal, the release banner's last version, the
channel manager's layout, the Plex playlist layout, the playlist rail's sort
and channel discovery's sort. usePref reads an older bare-string copy, and a
value only this browser held is saved to the server once, so the account's
other devices get it.

Performance mode is the device's: it is neither read from the account nor
saved to it.
This commit is contained in:
2026-10-20 23:04:48 +02:00
parent d6dada7fe5
commit f21cdfa156
29 changed files with 749 additions and 220 deletions
+57 -16
View File
@@ -3,7 +3,7 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api } from "./api";
import type { Me } from "./api/account";
import { readAccount, writeAccount } from "./storage";
import { getAccountRaw, writeAccount } from "./storage";
import { notifyActionError } from "./actionErrors";
import { updateMeCache } from "./useMe";
import i18n from "../i18n";
@@ -26,8 +26,8 @@ const reportPrefSaveFailure = (err: unknown) =>
// consumers reading the same key (e.g. a second player instance) see the change at once.
export interface UsePrefOptions<T> {
/** Also mirror to per-account localStorage under this base key; that copy seeds the value when the
* server payload lacks the key. */
/** Also mirror to the account's localStorage under this base key; that copy seeds the value (and
* the server, once) when the server payload lacks the key. */
localKey?: string;
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
* local state + cache still update instantly; only the network write is deferred. */
@@ -41,6 +41,45 @@ function readServerPref(me: Me | null | undefined, key: string): unknown {
return me?.preferences?.[key];
}
// The local copy as stored: JSON, or a bare string written before this hook held the key (a layout
// name, "1"), which is kept as that string for `parse` to map. Undefined when there is none.
function readLocalCopy(localKey: string): unknown {
const raw = getAccountRaw(localKey);
if (raw == null) return undefined;
try {
return JSON.parse(raw) as unknown;
} catch {
return raw;
}
}
/** Where a preference's value comes from: the server's, then the browser's own copy (a value it held
* before the server did, which `usePref` then saves to the server once), then the default. */
function resolvePref<T>(
me: Me | null | undefined,
serverKey: string,
defaultValue: T,
{ localKey, parse }: UsePrefOptions<T>,
): { value: T; fromLocal: boolean } {
const norm = (raw: unknown): T => (parse ? parse(raw) : (raw as T));
const server = readServerPref(me, serverKey);
if (server !== undefined) return { value: norm(server), fromLocal: false };
const local = localKey ? readLocalCopy(localKey) : undefined;
if (local !== undefined) return { value: norm(local), fromLocal: true };
return { value: defaultValue, fromLocal: false };
}
/** A preference's value outside a component (a landing page picked before the first render, a nudge
* decided from the account): the same order `usePref` seeds from. */
export function prefValue<T>(
me: Me | null | undefined,
serverKey: string,
defaultValue: T,
options: UsePrefOptions<T> = {},
): T {
return resolvePref(me, serverKey, defaultValue, options).value;
}
// Optimistically merge a preferences patch into the `me` cache so readers see it without a refetch —
// the shape mirrors the server's PUT /api/me/preferences merge. Shared by useSavePref and usePref.set
// (their sole difference is WHEN the server write fires — immediately vs debounced), so the cache-merge
@@ -71,21 +110,23 @@ export function usePref<T>(
defaultValue: T,
options: UsePrefOptions<T> = {},
): [T, (next: T) => void] {
const { localKey, debounceMs, parse } = options;
const { localKey, debounceMs } = options;
const qc = useQueryClient();
// Seeded inline rather than through a memoized `seed()`: useState calls a lazy initializer exactly
// once, at mount, so there was nothing for the useCallback to save — and its dep list was the one
// place in this file that had to be excused from the checker (it read `defaultValue` without
// naming it). Server preference wins, then the per-account localStorage mirror, then the default.
const [value, setValue] = useState<T>(() => {
// `parse` is read here only, once: the setter stores what it is given.
const norm = (raw: unknown): T => (parse ? parse(raw) : (raw as T));
const server = readServerPref(qc.getQueryData<Me | null>(qk.me()), serverKey);
if (server !== undefined) return norm(server);
if (localKey) return norm(readAccount(localKey, defaultValue));
return defaultValue;
});
// Read once, at mount (`parse` is read here only: the setter stores what it is given). Server
// preference wins, then this browser's copy, then the default.
const [seed] = useState(() =>
resolvePref(qc.getQueryData<Me | null>(qk.me()), serverKey, defaultValue, options),
);
const [value, setValue] = useState<T>(seed.value);
// A value only this browser held (the server has never stored the key) is saved to the server once,
// so the account's other devices get it too; from then on the server's wins everywhere.
useEffect(() => {
if (!seed.fromLocal) return;
const payload = { [serverKey]: seed.value };
mergePrefsCache(qc, payload);
api.savePrefs(payload).catch(reportPrefSaveFailure);
}, [seed, serverKey, qc]);
const saveTimer = useRef<number | undefined>(undefined);
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change