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
+2 -1
View File
@@ -1,3 +1,4 @@
import { LS } from "./storage";
import { createStore } from "./store";
// The debug recorder's app-wide state — what several surfaces open or read, held outside any
@@ -47,7 +48,7 @@ export const useComposerSession = (): string | null => composing.useValue();
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
// the list the issue is drafted from contradict itself.
const STEP_KEY = "siftlode.debugSteps";
const STEP_KEY = LS.debugSteps;
function readSteps(): Record<string, number> {
try {
+2 -1
View File
@@ -17,6 +17,7 @@
//
// Dependency-free on purpose (only the build stamp): the request layer, the notification store and
// the error boundary all log through here, so it must not import any of them.
import { LS } from "./storage";
import { FRONTEND_VERSION } from "./version";
export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
@@ -125,7 +126,7 @@ export function newRequestId(): string {
// Per TAB, not per page load: a reload loop is one tab's story, and sessionStorage is exactly that
// scope. Storage that throws (a locked-down browser) just means a fresh id per load.
const TAB_KEY = "siftlode.logTab";
const TAB_KEY = LS.logTab;
function tabId(): string {
try {
const known = sessionStorage.getItem(TAB_KEY);
+20 -16
View File
@@ -8,27 +8,32 @@
// ONBOARD_ACTIVE (sessionStorage) — set while the user is mid-flow (e.g. just clicked
// "Connect"); makes the wizard reopen after the
// redirect so they land on the next step.
// ONBOARD_DISMISSED (localStorage) — set when the user skips the essential read step;
// suppresses the auto-popup on future logins (they
// can still reopen it from Settings → Account).
// ONBOARDING_DISMISSED_PREF (an — set when the user skips the essential read step;
// account preference, usePref) suppresses the auto-popup on future logins, on every
// device (they can still reopen it from Settings → Account).
import { connectGoogle } from "./googleConnect";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
import { LS } from "./storage";
import type { UsePrefOptions } from "./usePref";
const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
// The account's (see ownKey): a fresh account should still get the onboarding nudge even if a
// different account in the same browser dismissed it.
const ONBOARD_DISMISSED = LS.onboardingDismissed;
const ONBOARD_ACTIVE = LS.onboardingActive; // sessionStorage, this tab only
// The account's, so a fresh account still gets the nudge where another one dismissed it. A copy this
// browser kept before the server held it is the string "1".
export const ONBOARDING_DISMISSED_PREF = "onboardingDismissed";
export const ONBOARDING_DISMISSED_OPTIONS: UsePrefOptions<boolean> = {
localKey: LS.onboardingDismissed,
parse: (raw) => raw === true || raw === "1" || raw === 1,
};
export function shouldAutoOpenOnboarding(me: {
can_read: boolean;
google_enabled?: boolean;
}): boolean {
export function shouldAutoOpenOnboarding(
me: { can_read: boolean; google_enabled?: boolean },
dismissed: boolean,
): boolean {
// YouTube connect needs Google OAuth configured on this instance. A self-host instance may run
// email+password only — then there's nothing to connect, so don't nudge.
if (me.google_enabled === false) return false;
if (sessionStorage.getItem(ONBOARD_ACTIVE)) return true;
return !me.can_read && !getAccountRaw(ONBOARD_DISMISSED);
return !me.can_read && !dismissed;
}
/** Mark a grant as in progress, then send the user to Google's consent screen. */
@@ -37,8 +42,7 @@ export function beginGrant(access: "read" | "write"): void {
connectGoogle(access);
}
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
export function endOnboarding(dismiss: boolean): void {
/** Leave the wizard's flow (its dismissal, if any, is the caller's preference to set). */
export function endOnboarding(): void {
sessionStorage.removeItem(ONBOARD_ACTIVE);
if (dismiss) setAccountRaw(ONBOARD_DISMISSED, "1");
}
+94 -62
View File
@@ -1,100 +1,129 @@
import { useCallback, useState } from "react";
// Central registry of every localStorage key the app uses. One place to see them all, rename
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
// files. Parametric (per-user) keys are functions. All keys are namespaced `siftlode.*`.
export const LS = {
theme: "siftlode.theme",
sidebarLayout: "siftlode.sidebarLayout",
plexLayout: "siftlode.plexLayout",
playlistsLayout: "siftlode.playlistsLayout",
playlistsCollapsed: "siftlode.playlistsCollapsed",
filesLayout: "siftlode.filesLayout",
filesCollapsed: "siftlode.filesCollapsed",
// Central registry of every key the app keeps in the browser (localStorage, and a tab's
// sessionStorage). One place to see them all, rename safely, and avoid collisions — instead of
// `"siftlode.x"` string literals scattered across files. All keys are namespaced `siftlode.*`, and each
// declares its kind: what it is decides where it belongs, and a contract test holds the rule per kind
// (storageKinds.contract.test.ts).
// pref a deliberate choice about the account (theme, a list's layout): it lives on the server and
// follows the account to every device — usePref, whose local copy only carries a value
// this browser held before the server did.
// device right for this screen, speaker or GPU and wrong on another (a panel width, the volume,
// performance mode): this browser only.
// view where the reader is (page, filters, a selected list, a sub-tab): this browser only.
// cache a copy of server data for a fast first paint, rebuilt freely.
// public the public /watch page's, which has no account: kept whoever signs in.
export type StorageKind = "pref" | "device" | "view" | "cache" | "public";
const KEYS = {
theme: { key: "siftlode.theme", kind: "pref" },
sidebarLayout: { key: "siftlode.sidebarLayout", kind: "device" },
plexLayout: { key: "siftlode.plexLayout", kind: "device" },
playlistsLayout: { key: "siftlode.playlistsLayout", kind: "device" },
playlistsCollapsed: { key: "siftlode.playlistsCollapsed", kind: "pref" },
filesLayout: { key: "siftlode.filesLayout", kind: "device" },
filesCollapsed: { key: "siftlode.filesCollapsed", kind: "pref" },
// The Files view last open (every file, shared with me, or a folder id), per-account.
filesView: "siftlode.filesView",
filesView: { key: "siftlode.filesView", kind: "view" },
// The Files table's rows-per-page and page (DataTable's persist blob), per-account.
filesTable: "siftlode.filesTable",
filesTable: { key: "siftlode.filesTable", kind: "device" },
// List or grid per Files view (a `{ viewParam: layout }` map), the sort both paints share, and the
// grid's cards per page — per-account mirrors of the server prefs (the last is local only).
filesLayouts: "siftlode.filesLayouts",
filesSort: "siftlode.filesSort",
filesCardSize: "siftlode.filesCardSize",
filesLayouts: { key: "siftlode.filesLayouts", kind: "pref" },
filesSort: { key: "siftlode.filesSort", kind: "pref" },
filesCardSize: { key: "siftlode.filesCardSize", kind: "device" },
// The download tray open or folded to its pill — a per-account mirror of the server pref.
downloadTray: "siftlode.downloadTray",
downloadTray: { key: "siftlode.downloadTray", kind: "pref" },
// The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
// changes. Per-account mirror of the server pref.
downloadTrayClosed: "siftlode.downloadTrayClosed",
downloadTrayClosed: { key: "siftlode.downloadTrayClosed", kind: "pref" },
// The download format used last — the download dialog opens on it. Per-account mirror of the
// server pref.
downloadProfile: "siftlode.downloadProfile",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
page: "siftlode.page",
downloadProfile: { key: "siftlode.downloadProfile", kind: "pref" },
hints: { key: "siftlode.hints", kind: "pref" },
lang: { key: "siftlode.lang", kind: "pref" },
filters: { key: "siftlode.filters", kind: "view" },
page: { key: "siftlode.page", kind: "view" },
// Start every session on the Home surface rather than on the module you happened to leave
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the account
// is claimed (claimLocalData) — so the landing is decided right before the first paint, never
// corrected after.
startOnHome: "siftlode.startOnHome",
perfMode: "siftlode.perfMode",
startOnHome: { key: "siftlode.startOnHome", kind: "pref" },
perfMode: { key: "siftlode.perfMode", kind: "device" },
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
// from only when the server has never stored the key.
feedView: "siftlode.feedView",
feedView: { key: "siftlode.feedView", kind: "pref" },
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
// the old single-string `channelFilter` sync chip.
channelsFilterMap: "siftlode.channelsFilterMap",
channelsFilterMap: { key: "siftlode.channelsFilterMap", kind: "view" },
// DEPRECATED — the retired single-value sync chip; read once to migrate a pinned value into
// `channelsFilterMap`, then inert. Do not write.
legacyChannelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView",
legacyChannelFilter: { key: "siftlode.channelFilter", kind: "view" },
channelsView: { key: "siftlode.channelsView", kind: "view" },
// The channel manager's layout (table / cards — see lib/channelLayout.ts), per-account.
channelLayout: "siftlode.channelLayout",
channelLayout: { key: "siftlode.channelLayout", kind: "pref" },
// The manager's sort, lifted out of the table so the card layout shares it (per-account, per-tab).
channelSort: "siftlode.channelSort",
channelShow: "siftlode.channelShow",
channelDiscoverySort: "siftlode.channelDiscoverySort",
channelSort: { key: "siftlode.channelSort", kind: "view" },
channelShow: { key: "siftlode.channelShow", kind: "pref" },
channelDiscoverySort: { key: "siftlode.channelDiscoverySort", kind: "pref" },
// Cards-per-page for the card layout (the table keeps its own rows-per-page in its persist blob).
channelCardSize: "siftlode.channelCardSize",
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
channelsTable: "siftlode.channelsTable",
channelCardSize: { key: "siftlode.channelCardSize", kind: "device" },
channelDiscoveryCardSize: { key: "siftlode.channelDiscoveryCardSize", kind: "device" },
channelsTable: { key: "siftlode.channelsTable", kind: "device" },
// Channel id → name as last seen (useChannelNames): names a channel filter's chips after a reload,
// before the subscription list has landed. A cache, rewritten from the list on every load.
channelNames: "siftlode.channelNames",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
channelNames: { key: "siftlode.channelNames", kind: "cache" },
channelDiscoveryTable: { key: "siftlode.channelDiscoveryTable", kind: "device" },
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab
// below.
settingsTab: "siftlode.settingsTab",
configTab: "siftlode.configTab",
adminUsersTab: "siftlode.adminUsersTab",
filterCollapsed: "siftlode.filterCollapsed",
playlist: "siftlode.playlist",
plSort: "siftlode.plSort",
plexLibrary: "siftlode.plexLibrary",
plexShow: "siftlode.plexShow",
plexSort: "siftlode.plexSort",
plexFilters: "siftlode.plexFilters",
plexQ: "siftlode.plexQ",
plexField: "siftlode.plexField",
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
settingsTab: { key: "siftlode.settingsTab", kind: "view" },
configTab: { key: "siftlode.configTab", kind: "view" },
adminUsersTab: { key: "siftlode.adminUsersTab", kind: "view" },
filterCollapsed: { key: "siftlode.filterCollapsed", kind: "pref" },
playlist: { key: "siftlode.playlist", kind: "view" },
plSort: { key: "siftlode.plSort", kind: "pref" },
plexLibrary: { key: "siftlode.plexLibrary", kind: "view" },
plexShow: { key: "siftlode.plexShow", kind: "view" },
plexSort: { key: "siftlode.plexSort", kind: "view" },
plexFilters: { key: "siftlode.plexFilters", kind: "view" },
plexQ: { key: "siftlode.plexQ", kind: "view" },
plexField: { key: "siftlode.plexField", kind: "view" },
plexPlaylistLayout: { key: "siftlode.plexPlaylistLayout", kind: "pref" },
plexPlayerPrefs: { key: "siftlode.plexPlayerPrefs", kind: "device" },
// The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate
// flag). A YT.Player instance is created per video and starts at full volume, so without this
// every next video is loud again (same idea as plexPlayerPrefs).
ytPlayerPrefs: "siftlode.ytPlayerPrefs",
notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed",
seenVersion: "siftlode.seenVersion",
ytPlayerPrefs: { key: "siftlode.ytPlayerPrefs", kind: "device" },
notifHistory: { key: "siftlode.notifications", kind: "cache" },
notifSettings: { key: "siftlode.notifSettings", kind: "pref" },
onboardingDismissed: { key: "siftlode.onboarding.dismissed", kind: "pref" },
seenVersion: { key: "siftlode.seenVersion", kind: "pref" },
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
// that page runs outside the app, with no account at all.
watchRepeat: "siftlode.watchRepeat",
watchRepeat: { key: "siftlode.watchRepeat", kind: "public" },
// Where this browser stopped in each shared /watch link (`lib/resume.ts`: token -> {at, t}). Same
// reason as above: the viewer has no account to keep it in.
watchResume: "siftlode.watchResume",
} as const;
watchResume: { key: "siftlode.watchResume", kind: "public" },
// A tab's own state, kept in sessionStorage outside the hooks above: the onboarding flow in
// progress across Google's consent redirect, the debug recorder's steps, the log's tab id.
onboardingActive: { key: "siftlode.onboarding.active", kind: "view" },
debugSteps: { key: "siftlode.debugSteps", kind: "view" },
logTab: { key: "siftlode.logTab", kind: "device" },
} as const satisfies Record<string, { key: `siftlode.${string}`; kind: StorageKind }>;
/** Every key the app stores, by name. */
export const LS = Object.fromEntries(
Object.entries(KEYS).map(([name, { key }]) => [name, key]),
) as {
readonly [N in keyof typeof KEYS]: (typeof KEYS)[N]["key"];
};
/** Each stored key's kind (see StorageKind), for the rules that hold per kind. */
export const KIND_OF: ReadonlyMap<string, StorageKind> = new Map(
Object.values(KEYS).map(({ key, kind }) => [key, kind]),
);
// --- the signed-in account's local state --------------------------------------------------
// A browser session holds ONE account, so its local state needs no account id in the key: the browser
@@ -105,12 +134,15 @@ export const LS = {
const OWNER_KEY = "siftlode.owner";
// Kept whoever signs in: they belong to the browser, not to an account (the public /watch page has none).
const BROWSER_KEYS: ReadonlySet<string> = new Set([LS.watchRepeat, LS.watchResume, OWNER_KEY]);
const BROWSER_KEYS: ReadonlySet<string> = new Set([
OWNER_KEY,
...[...KIND_OF].flatMap(([key, kind]) => (kind === "public" ? [key] : [])),
]);
// The multi-account build pinned each tab to an account (sessionStorage) and suffixed every key with
// its id (`siftlode.filters.5`). A claim moves the account's own copies onto the plain keys and drops
// the other accounts' copies; only keys the registry knows are touched.
const LEGACY_PIN_KEYS = ["siftlode.activeAccount", "siftlode.nextAccount"];
const REGISTERED: ReadonlySet<string> = new Set(Object.values(LS));
const REGISTERED: ReadonlySet<string> = new Set(KIND_OF.keys());
const ACCOUNT_COPY = /^(.+)\.(\d+)$/;
let claimed: string | null = null;
@@ -0,0 +1,79 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { KIND_OF, LS } from "./storage";
// Every key the app keeps in the browser is declared in storage.ts with its kind (a key without one
// does not compile), and the kind decides where it belongs. This holds the two ways that rule drifts:
// a key spelled out somewhere else, so the registry never learns of it, and an account preference
// read or written straight from localStorage, so it never reaches the server and stays on one device
// while every other device shows something else.
const SRC = new URL("../", import.meta.url);
const SKIP_DIRS = new Set(["node_modules", "design-lab"]);
const TEST = /\.test\.tsx?$/;
function files(dir: string): string[] {
return readdirSync(new URL(dir, SRC), { withFileTypes: true }).flatMap((e) =>
e.isDirectory()
? SKIP_DIRS.has(e.name)
? []
: files(`${dir}${e.name}/`)
: /\.tsx?$/.test(e.name) && !TEST.test(e.name)
? [`${dir}${e.name}`]
: [],
);
}
const SOURCES = files("").map((f) => ({ f, text: readFileSync(new URL(f, SRC), "utf-8") }));
// Preferences the Settings page owns (PrefsProvider saves them to the server) whose local copy is
// kept by the store that applies them: it paints the first frame, and for the language the
// signed-out pages too, before the account is known.
const SYNCED_BY_STORE: Record<string, string> = {
theme: "lib/theme.ts",
hints: "lib/hints.ts",
notifSettings: "lib/notifications.ts",
lang: "i18n/index.ts",
};
describe("storage kinds", () => {
it("spells out no key outside the registry", () => {
// The debug recorder's BroadcastChannel is a channel name, not a stored key.
const allowed = new Set(['"siftlode.debugSession"']);
const hits = SOURCES.filter(({ f }) => f !== "lib/storage.ts").flatMap(({ f, text }) =>
[...text.matchAll(/["'`]siftlode\.[\w.]+["'`]/g)]
.map((m) => m[0])
.filter((lit) => !allowed.has(lit))
.map((lit) => `${f}: ${lit}`),
);
expect(hits).toEqual([]);
});
it("keeps an account preference on the server: its local copy only through usePref", () => {
const prefs = Object.entries(LS).filter(([, key]) => KIND_OF.get(key) === "pref");
expect(prefs.length).toBeGreaterThan(10);
const hits = prefs.flatMap(([name]) =>
SOURCES.flatMap(({ f, text }) => {
if (f === "lib/storage.ts" || SYNCED_BY_STORE[name] === f) return [];
const named = text.match(new RegExp(`\\bLS\\.${name}\\b`, "g"))?.length ?? 0;
const asLocalCopy =
text.match(new RegExp(`\\blocalKey:\\s*LS\\.${name}\\b`, "g"))?.length ?? 0;
return named > asLocalCopy ? [`${f}: LS.${name}`] : [];
}),
);
expect(hits).toEqual([]);
});
it("lists only preferences as kept by their store", () => {
for (const name of Object.keys(SYNCED_BY_STORE))
expect(KIND_OF.get(LS[name as keyof typeof LS]), name).toBe("pref");
});
// The e2e setup seeds a browser's state by hand: a key it writes that the app no longer reads would
// keep a spec green while setting up nothing.
it("seeds the e2e browsers with registered keys only", () => {
const setup = readFileSync(new URL("../e2e/global.setup.ts", SRC), "utf-8");
const keys = [...setup.matchAll(/["'`](siftlode\.[\w.]+)["'`]/g)].map((m) => m[1]!);
expect(keys.length).toBeGreaterThan(0);
expect(keys.filter((k) => k !== "siftlode.owner" && !KIND_OF.has(k))).toEqual([]);
});
});
+47 -4
View File
@@ -17,10 +17,7 @@ const { store, savePrefs } = vi.hoisted(() => ({
// Partial: i18n (which usePref now imports, to title a refused save) reads its key from `LS`.
vi.mock("./storage", async (importOriginal) => ({
...(await importOriginal<typeof import("./storage")>()),
readAccount: (base: string, fallback: unknown) => {
const raw = store.get(base);
return raw == null ? fallback : JSON.parse(raw);
},
getAccountRaw: (base: string) => store.get(base) ?? null,
writeAccount: (base: string, value: unknown) => {
store.set(base, JSON.stringify(value));
},
@@ -77,6 +74,52 @@ describe("usePref", () => {
expect(result.current[0]).toBe("tiles");
});
// A copy written before usePref held the key is a bare string ("cards", "1"), not JSON.
it("reads a bare-string copy as that string, through parse", () => {
const qc = makeClient();
store.set("siftlode.plexPlaylistLayout", "tree");
const { result } = renderHook(
() =>
usePref<string>("plexPlaylistLayout", "accordion", {
localKey: "siftlode.plexPlaylistLayout",
parse: (raw) => (raw === "tree" ? "tree" : "accordion"),
}),
{ wrapper: wrapperFor(qc) },
);
expect(result.current[0]).toBe("tree");
});
// A value only this browser held reaches the server, so the account's other devices get it: once,
// and only while the server has none.
it("saves a value only this browser held to the server, once", () => {
const qc = makeClient();
seedMe(qc, {});
store.set("siftlode.feedView", JSON.stringify("tiles"));
const useViewPref = () => usePref<string>("view", "cards", { localKey: "siftlode.feedView" });
renderHook(useViewPref, { wrapper: wrapperFor(qc) });
expect(savePrefs).toHaveBeenCalledExactlyOnceWith({ view: "tiles" });
expect(qc.getQueryData<{ preferences: Record<string, unknown> }>(qk.me())?.preferences).toEqual(
{ view: "tiles" },
);
renderHook(useViewPref, { wrapper: wrapperFor(qc) }); // a second instance: the server has it now
expect(savePrefs).toHaveBeenCalledOnce();
});
it("sends nothing at mount when the server has the value, or nobody has one", () => {
const qc = makeClient();
seedMe(qc, { view: "rows" });
store.set("siftlode.feedView", JSON.stringify("tiles"));
renderHook(() => usePref<string>("view", "cards", { localKey: "siftlode.feedView" }), {
wrapper: wrapperFor(qc),
});
store.clear();
renderHook(() => usePref<string>("other", "x", { localKey: "siftlode.feedView" }), {
wrapper: wrapperFor(qc),
});
expect(savePrefs).not.toHaveBeenCalled();
});
it("prefers the server value over the mirror", () => {
const qc = makeClient();
seedMe(qc, { view: "rows" });
+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
+8 -2
View File
@@ -1,10 +1,16 @@
// Build info baked into the SPA at build time (Vite inlines VITE_*-prefixed env).
// Injected via Docker build-args (see Dockerfile); falls back to "dev" for un-stamped builds.
import { LS } from "./storage";
import type { UsePrefOptions } from "./usePref";
export const FRONTEND_VERSION = import.meta.env.VITE_APP_VERSION || "dev";
export const FRONTEND_SHA = import.meta.env.VITE_GIT_SHA || "unknown";
export const FRONTEND_BUILD_DATE = import.meta.env.VITE_BUILD_DATE || "";
// Key for the "last version the user has seen release notes for" (drives the banner).
export const SEEN_VERSION_KEY = LS.seenVersion;
// The last version the account has seen the release notes banner for — an account preference
// (usePref), so a banner dismissed on one device stays dismissed on the others.
export const SEEN_VERSION_PREF = "seenVersion";
export const SEEN_VERSION_OPTIONS: UsePrefOptions<string | null> = {
localKey: LS.seenVersion,
parse: (raw) => (raw == null ? null : String(raw)),
};