Files
siftlode/frontend/src/lib/usePref.ts
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

131 lines
6.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
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 { notifyActionError } from "./actionErrors";
import { updateMeCache } from "./useMe";
import i18n from "../i18n";
// The local state + cache already show the new value, so a refused save is otherwise invisible until
// the next load quietly puts the old one back. `req` reports none of 403/404/429 and only the
// connection notice for a lost connection — say it.
const reportPrefSaveFailure = (err: unknown) =>
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
// 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 follows the
// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and
// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new
// server-synced pref is a one-liner.
//
// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the
// app tree only once /api/me has answered, and a tab whose account changes reloads, so there is nothing
// to adopt after mount. 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; that copy seeds the value 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. */
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];
}
// 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
// shape lives in ONE place.
function mergePrefsCache(qc: QueryClient, patch: Record<string, unknown>): void {
updateMeCache(qc, (m) => ({ ...m, preferences: { ...m.preferences, ...patch } }));
}
/** 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(reportPrefSaveFailure);
mergePrefsCache(qc, patch);
},
[qc],
);
}
/** A per-account preference persisted to the server (and optionally mirrored to localStorage).
* 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();
// 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;
});
const saveTimer = useRef<number | undefined>(undefined);
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
// isn't lost. Dropping it would leave the server stale while local/cache/localStorage hold the new
// value, and the next load — which seeds from the server first — would bring the old value back.
const pendingSave = useRef<(() => void) | null>(null);
useEffect(
() => () => {
window.clearTimeout(saveTimer.current);
pendingSave.current?.();
pendingSave.current = null;
},
[],
);
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 a later mount of this hook,
// which seeds from the cache) see the new value without a refetch. Merges into preferences, mirroring the server PUT.
const payload = { [serverKey]: next };
mergePrefsCache(qc, payload);
// savePrefs is fire-and-forget (idempotent PUT, so `req` retries a transient blip once); a
// failure that survives that is reported, not swallowed.
const flush = () => api.savePrefs(payload).catch(reportPrefSaveFailure);
if (debounceMs) {
window.clearTimeout(saveTimer.current);
pendingSave.current = flush;
saveTimer.current = window.setTimeout(() => {
pendingSave.current = null;
void flush();
}, debounceMs);
} else {
void flush();
}
},
[qc, serverKey, localKey, debounceMs],
);
return [value, set];
}