feat(prefs): usePref — one per-account+server preference hook (E4 S1)

Fold the hand-rolled server-pref plumbing (savePrefs + local mirror + me-cache
merge + adopt-on-login) into a single usePref<T>(serverKey, default, opts) hook,
plus useSavePref for dynamic multi-key patches.

Migrated (6 savePrefs call sites + 2 duplicated adopt effects -> the hook):
- FeedViewProvider: view
- App: filterCollapsed, playlistsCollapsed (+ their adopt block removed)
- PlayerModal: playerAutoAdvance, playerLoop
- plexDetailUi: plexInfoArtBg/Cast/Related (savePref via useSavePref)

Left as-is by design: panel layouts (a dynamically-indexed Record, not one key),
language (i18n store, no React state), PrefsProvider (debounced multi-field
auto-save + baseline echo-suppression - a different model).

Behaviour-equivalent (net -84 LOC at the call sites). usePref.dom.test covers
the three seed sources, persist path, debounce, adopt, parse, and useSavePref.
Frontend gate green: typecheck, knip, eslint (0 err), prettier, 238 vitest.
This commit is contained in:
2026-08-19 03:07:25 +02:00
parent c82cf25bcf
commit 48aa1a25f5
6 changed files with 354 additions and 132 deletions
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api } from "./api";
import type { Me } from "./api/account";
import { readAccount, writeAccount } from "./storage";
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its
// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache,
// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds
// all three into one hook so a new server-synced pref is a one-liner.
//
// Two mirror strategies, unified here (pick via `localKey`):
// • Providers mounted ABOVE <App> (feed view, panel layouts, rail-collapse flags) render before
// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on
// reload — pass `localKey`.
// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they
// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`.
// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other
// 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, and seed synchronously from it on
* first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
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. */
debounceMs?: number;
/** Normalize a raw stored value (from the server payload or the localStorage mirror) into T — e.g.
* mapping legacy values forward, or clamping. Defaults to identity. Must be idempotent. */
parse?: (raw: unknown) => T;
}
function readServerPref(me: Me | null | undefined, key: string): unknown {
return me?.preferences?.[key];
}
/** The raw preference persister: save an arbitrary partial preferences patch to the server and merge
* it into the `me` cache optimistically (so readers see it without a refetch). For dynamic or
* multi-key prefs that don't map to a single stateful usePref call — e.g. a variable set of toggles
* (PlexInfo's per-collection strip-source hiding). Prefer usePref for a fixed, stateful key. */
export function useSavePref(): (patch: Record<string, unknown>) => void {
const qc = useQueryClient();
return useCallback(
(patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<Me | null>(qk.me(), (m) =>
m ? { ...m, preferences: { ...m.preferences, ...patch } } : m,
);
},
[qc],
);
}
/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with
* automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */
export function usePref<T>(
serverKey: string,
defaultValue: T,
options: UsePrefOptions<T> = {},
): [T, (next: T) => void] {
const { localKey, debounceMs, parse } = options;
const qc = useQueryClient();
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which
// would churn the memoized setter and the adopt effect). parse must be idempotent anyway.
const parseRef = useRef(parse);
parseRef.current = parse;
const norm = useCallback((raw: unknown): T => {
const p = parseRef.current;
return p ? p(raw) : (raw as T);
}, []);
const seed = useCallback((): 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;
// defaultValue / serverKey / localKey are stable for a given call site.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qc, serverKey, localKey, norm]);
const [value, setValue] = useState<T>(seed);
const saveTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(saveTimer.current), []);
const set = useCallback(
(next: T) => {
setValue(next);
if (localKey) writeAccount(localKey, next);
// Keep the `me` cache in step so other readers of this key (and this hook's own adopt effect)
// see the new value without a refetch. Merges into preferences, mirroring the server PUT.
qc.setQueryData<Me | null>(qk.me(), (m) =>
m ? { ...m, preferences: { ...m.preferences, [serverKey]: next } } : m,
);
const payload = { [serverKey]: next };
// savePrefs is fire-and-forget: api.req() already surfaces a failure toast, and the local state +
// cache already reflect the change (idempotent PUT retries a transient blip).
if (debounceMs) {
window.clearTimeout(saveTimer.current);
saveTimer.current = window.setTimeout(() => {
api.savePrefs(payload).catch(() => {});
}, debounceMs);
} else {
api.savePrefs(payload).catch(() => {});
}
},
[qc, serverKey, localKey, debounceMs],
);
// Adopt the server value when the account becomes known / changes (login, account switch). Guarded on
// the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key →
// keep the current (seeded / user-set) value rather than clobbering it with the default.
const prevId = useRef<number | null | undefined>(me?.id);
useEffect(() => {
if (me?.id === prevId.current) return;
prevId.current = me?.id;
const server = readServerPref(me, serverKey);
if (server === undefined) return;
const val = norm(server);
setValue(val);
if (localKey) writeAccount(localKey, val);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id]);
return [value, set];
}