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:
+16
-30
@@ -15,7 +15,8 @@ import {
|
||||
type PanelLayout,
|
||||
} from "./lib/panelLayout";
|
||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||
import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage";
|
||||
import { LS, setAccountRaw } from "./lib/storage";
|
||||
import { usePref } from "./lib/usePref";
|
||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
@@ -90,26 +91,18 @@ export default function App() {
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
// user's preferences so it follows the account across devices; a localStorage cache seeds the
|
||||
// initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||
// user's preferences (via usePref) so it follows the account across devices; the localStorage mirror
|
||||
// seeds the initial render synchronously so nothing flashes open before the server prefs arrive.
|
||||
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||
const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
|
||||
Boolean(readAccount(LS.filterCollapsed, true)),
|
||||
);
|
||||
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
||||
localKey: LS.filterCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||
const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
|
||||
Boolean(readAccount(LS.playlistsCollapsed, true)),
|
||||
);
|
||||
function setFilterCollapsed(next: boolean) {
|
||||
setFilterCollapsedState(next);
|
||||
writeAccount(LS.filterCollapsed, next);
|
||||
api.savePrefs({ filterCollapsed: next }).catch(() => {});
|
||||
}
|
||||
function setPlaylistsCollapsed(next: boolean) {
|
||||
setPlaylistsCollapsedState(next);
|
||||
writeAccount(LS.playlistsCollapsed, next);
|
||||
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
|
||||
}
|
||||
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
|
||||
localKey: LS.playlistsCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
|
||||
// link, its params have already hydrated the initial state — persist them and strip the
|
||||
// query so the URL stays clean from here on.
|
||||
@@ -213,9 +206,10 @@ export default function App() {
|
||||
stripUrlParams();
|
||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
|
||||
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
|
||||
// and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
|
||||
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
|
||||
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
|
||||
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
|
||||
// FeedViewProvider — each via its own passive qk.me() observer.
|
||||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
if (!prefs) return;
|
||||
@@ -227,14 +221,6 @@ export default function App() {
|
||||
saveLayoutLocal(p, l);
|
||||
}
|
||||
});
|
||||
if (typeof prefs.filterCollapsed === "boolean") {
|
||||
setFilterCollapsedState(prefs.filterCollapsed);
|
||||
writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
|
||||
}
|
||||
if (typeof prefs.playlistsCollapsed === "boolean") {
|
||||
setPlaylistsCollapsedState(prefs.playlistsCollapsed);
|
||||
writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed);
|
||||
}
|
||||
const lang = prefs.language;
|
||||
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
|
||||
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../lib/api";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { parseFeedView, type FeedView } from "../lib/feedView";
|
||||
import { LS, readAccount, writeAccount } from "../lib/storage";
|
||||
import { LS } from "../lib/storage";
|
||||
import { usePref } from "../lib/usePref";
|
||||
|
||||
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
|
||||
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
|
||||
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
|
||||
//
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs)
|
||||
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
|
||||
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
|
||||
// render synchronously; the passive qk.me() observer adopts the server value once the account resolves.
|
||||
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
|
||||
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
|
||||
// that seeds the first render synchronously) so this provider is just the two contexts over it.
|
||||
|
||||
interface FeedViewActions {
|
||||
setView: (v: FeedView) => void;
|
||||
@@ -37,30 +26,11 @@ export function useFeedViewActions(): FeedViewActions {
|
||||
}
|
||||
|
||||
export function FeedViewProvider({ children }: { children: ReactNode }) {
|
||||
const [view, setViewState] = useState<FeedView>(() =>
|
||||
parseFeedView(readAccount(LS.feedView, null)),
|
||||
);
|
||||
// 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 });
|
||||
|
||||
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
|
||||
const setView = useCallback((next: FeedView) => {
|
||||
setViewState(next);
|
||||
writeAccount(LS.feedView, next);
|
||||
api.savePrefs({ view: next }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs
|
||||
// (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45
|
||||
// "grid" / "list" values forward.
|
||||
useEffect(() => {
|
||||
const prefs = me?.preferences;
|
||||
if (!prefs) return;
|
||||
const adopted = parseFeedView(prefs.view);
|
||||
setViewState(adopted);
|
||||
writeAccount(LS.feedView, adopted);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [me?.id]);
|
||||
// parseFeedView also maps the pre-0.45 "grid"/"list" values forward (idempotent for current values).
|
||||
const [view, setView] = usePref<FeedView>("view", "cards", {
|
||||
localKey: LS.feedView,
|
||||
parse: parseFeedView,
|
||||
});
|
||||
|
||||
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
@@ -481,36 +482,18 @@ export default function PlayerModal({
|
||||
p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
|
||||
};
|
||||
|
||||
// Auto-advance + loop are persistent per-user settings. Read the current values from the cached
|
||||
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change
|
||||
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
|
||||
const savedPrefs = useMemo(
|
||||
() =>
|
||||
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ?? {}) as {
|
||||
playerAutoAdvance?: AutoMode;
|
||||
playerLoop?: LoopMode;
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
|
||||
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
|
||||
// Auto-advance + loop are persistent per-user settings (usePref: server + `me` cache, adopted on
|
||||
// login) so they apply to every player and survive reloads. The player mounts after `me` is cached,
|
||||
// so it seeds from the cache — no localStorage mirror. A ref feeds the (once-bound) end handler.
|
||||
const [autoMode, setAutoMode] = usePref<AutoMode>("playerAutoAdvance", "off");
|
||||
const [loopMode, setLoopMode] = usePref<LoopMode>("playerLoop", "off");
|
||||
const modeRef = useRef({ autoMode, loopMode });
|
||||
modeRef.current = { autoMode, loopMode };
|
||||
const persistPref = (patch: Record<string, string>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||
);
|
||||
};
|
||||
const cycleAuto = () => {
|
||||
const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!;
|
||||
setAutoMode(next);
|
||||
persistPref({ playerAutoAdvance: next });
|
||||
setAutoMode(AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length]!);
|
||||
};
|
||||
const cycleLoop = () => {
|
||||
const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!;
|
||||
setLoopMode(next);
|
||||
persistPref({ playerLoop: next });
|
||||
setLoopMode(LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length]!);
|
||||
};
|
||||
const replayCurrent = () => {
|
||||
const p = playerRef.current;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { qk } from "./queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { useDismiss } from "./useDismiss";
|
||||
import { api } from "./api";
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
|
||||
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||
@@ -12,42 +10,21 @@ import { api } from "./api";
|
||||
// customize menu that toggles them.
|
||||
|
||||
export function useDetailPrefs() {
|
||||
const qc = useQueryClient();
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||||
{}) as {
|
||||
plexInfoArtBg?: boolean;
|
||||
plexInfoCast?: boolean;
|
||||
plexInfoRelated?: boolean;
|
||||
};
|
||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||||
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
|
||||
const save = (patch: Record<string, unknown>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(qk.me(), (m) =>
|
||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||
);
|
||||
};
|
||||
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
||||
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
|
||||
// usePref seeds from the cache (no localStorage mirror).
|
||||
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
||||
const savePref = useSavePref();
|
||||
return {
|
||||
artBg,
|
||||
showCast,
|
||||
showRelated,
|
||||
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||
toggleArtBg: () => {
|
||||
const next = !artBg;
|
||||
setArtBg(next);
|
||||
save({ plexInfoArtBg: next });
|
||||
},
|
||||
toggleCast: () => {
|
||||
const next = !showCast;
|
||||
setShowCast(next);
|
||||
save({ plexInfoCast: next });
|
||||
},
|
||||
toggleRelated: () => {
|
||||
const next = !showRelated;
|
||||
setShowRelated(next);
|
||||
save({ plexInfoRelated: next });
|
||||
},
|
||||
savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||
toggleArtBg: () => setArtBg(!artBg),
|
||||
toggleCast: () => setShowCast(!showCast),
|
||||
toggleRelated: () => setShowRelated(!showRelated),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache /
|
||||
// localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror),
|
||||
// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the
|
||||
// server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
|
||||
const { store, savePrefs } = vi.hoisted(() => ({
|
||||
store: new Map<string, string>(),
|
||||
savePrefs: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("./storage", () => ({
|
||||
readAccount: (base: string, fallback: unknown) => {
|
||||
const raw = store.get(base);
|
||||
return raw == null ? fallback : JSON.parse(raw);
|
||||
},
|
||||
writeAccount: (base: string, value: unknown) => {
|
||||
store.set(base, JSON.stringify(value));
|
||||
},
|
||||
}));
|
||||
vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
|
||||
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
function seedMe(qc: QueryClient, preferences: Record<string, unknown>, id = 1) {
|
||||
qc.setQueryData(qk.me(), { id, preferences });
|
||||
}
|
||||
function wrapperFor(qc: QueryClient) {
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
createElement(QueryClientProvider, { client: qc }, children);
|
||||
}
|
||||
|
||||
describe("usePref", () => {
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
savePrefs.mockClear();
|
||||
});
|
||||
|
||||
it("seeds from the default when neither server nor mirror has the key", () => {
|
||||
const qc = makeClient();
|
||||
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
expect(result.current[0]).toBe("cards");
|
||||
});
|
||||
|
||||
it("seeds from the server (me cache) when present", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "rows" });
|
||||
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
expect(result.current[0]).toBe("rows");
|
||||
});
|
||||
|
||||
it("seeds from the localStorage mirror when there is no server value", () => {
|
||||
const qc = makeClient();
|
||||
store.set("siftlode.feedView", JSON.stringify("tiles"));
|
||||
const { result } = renderHook(
|
||||
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||
{ wrapper: wrapperFor(qc) },
|
||||
);
|
||||
expect(result.current[0]).toBe("tiles");
|
||||
});
|
||||
|
||||
it("prefers the server value over the mirror", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "rows" });
|
||||
store.set("siftlode.feedView", JSON.stringify("tiles"));
|
||||
const { result } = renderHook(
|
||||
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||
{ wrapper: wrapperFor(qc) },
|
||||
);
|
||||
expect(result.current[0]).toBe("rows");
|
||||
});
|
||||
|
||||
it("applies parse to a raw stored value", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "GRID" });
|
||||
const { result } = renderHook(
|
||||
() => usePref<string>("view", "cards", { parse: (r) => String(r).toLowerCase() }),
|
||||
{ wrapper: wrapperFor(qc) },
|
||||
);
|
||||
expect(result.current[0]).toBe("grid");
|
||||
});
|
||||
|
||||
it("on set: updates state, writes the server, merges the me cache, and the mirror", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, {});
|
||||
const { result } = renderHook(
|
||||
() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }),
|
||||
{ wrapper: wrapperFor(qc) },
|
||||
);
|
||||
act(() => result.current[1]("rows"));
|
||||
expect(result.current[0]).toBe("rows");
|
||||
expect(savePrefs).toHaveBeenCalledWith({ view: "rows" });
|
||||
expect(
|
||||
(qc.getQueryData(qk.me()) as { preferences: Record<string, unknown> }).preferences.view,
|
||||
).toBe("rows");
|
||||
expect(store.get("siftlode.feedView")).toBe(JSON.stringify("rows"));
|
||||
});
|
||||
|
||||
it("does not write a localStorage mirror when no localKey is given", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, {});
|
||||
const { result } = renderHook(() => usePref<string>("playerLoop", "off"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
act(() => result.current[1]("all"));
|
||||
expect(store.size).toBe(0);
|
||||
expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" });
|
||||
});
|
||||
|
||||
it("adopts the server value when the account id changes (login / switch)", async () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { view: "cards" }, 1);
|
||||
const { result } = renderHook(() => usePref<string>("view", "cards"), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
expect(result.current[0]).toBe("cards");
|
||||
// A different account resolves. The passive me observer notifies asynchronously (react-query's
|
||||
// notifyManager batches), so wait for the re-render — the same path the real App drives on login.
|
||||
act(() => seedMe(qc, { view: "tiles" }, 2));
|
||||
await waitFor(() => expect(result.current[0]).toBe("tiles"));
|
||||
});
|
||||
|
||||
describe("debounceMs", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("coalesces rapid sets into one PUT after the delay", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, {});
|
||||
const { result } = renderHook(() => usePref<number>("vol", 0, { debounceMs: 300 }), {
|
||||
wrapper: wrapperFor(qc),
|
||||
});
|
||||
act(() => {
|
||||
result.current[1](10);
|
||||
result.current[1](20);
|
||||
result.current[1](30);
|
||||
});
|
||||
// Local state is instant; the server write is deferred.
|
||||
expect(result.current[0]).toBe(30);
|
||||
expect(savePrefs).not.toHaveBeenCalled();
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(savePrefs).toHaveBeenCalledTimes(1);
|
||||
expect(savePrefs).toHaveBeenCalledWith({ vol: 30 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavePref", () => {
|
||||
beforeEach(() => savePrefs.mockClear());
|
||||
|
||||
it("persists an arbitrary patch and merges it into the me cache", () => {
|
||||
const qc = makeClient();
|
||||
seedMe(qc, { a: 1 });
|
||||
const { result } = renderHook(() => useSavePref(), { wrapper: wrapperFor(qc) });
|
||||
act(() => result.current({ plexHiddenStripSources: ["x"] }));
|
||||
expect(savePrefs).toHaveBeenCalledWith({ plexHiddenStripSources: ["x"] });
|
||||
const prefs = (qc.getQueryData(qk.me()) as { preferences: Record<string, unknown> })
|
||||
.preferences;
|
||||
expect(prefs).toEqual({ a: 1, plexHiddenStripSources: ["x"] });
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user