fix(boot): a new tab knows its account before anything renders

With Settings -> Appearance -> Startup turned off, every NEW tab or window still
opened on Home; only a same-tab reload honoured it. The provider tree mounted
before /api/me answered, so NavigationProvider's initializer could not read the
per-account opt-out, landed on Home and mirrored ?page=home into the address bar.
When App pinned the account a moment later, the re-hydrate re-read that mirror
and stayed on Home. The same mount-then-patch shape made a new tab start on
default filters, the default theme and the browser language, and correct each
after the first paint.

BootGate (main.tsx, above the providers) now owns the setup-status and /api/me
queries and mounts the tree only once the identity is settled: it pins the tab's
account and adopts the account's language in a layout effect, so the first frame
of the app is already right. The tree is keyed on the account id, which makes
"per-account state is read once, for this account" a structural guarantee.

With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the
reactive active-account store, the markDirty guards, the pin-deferred saved-view
seed and the present-dimension bookkeeping of the shared Channels/Plex links.
PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the
me cache instead of adopting it in an effect; the theme is applied in layout
effects. The app chunk is fetched in parallel with the probe, and the boot screen
only fades its label in after 400 ms, on one clock across the gate and the
Suspense fallback.

Module switches no longer flash a placeholder either. Measured frame by frame
in Chromium and WebKit, every first visit to a module showed "Loading..." for
17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's
LoadingState. Pages are now lazyWithPreload components, and App fetches the
chunks of every module the account can reach (plus the channel page) once the
first page is idle; a preloaded page renders synchronously and never suspends.
LoadingState, the one shared loading visual, fades in only after 300 ms, so a
fast load shows nothing in between and a slow one still gets its spinner.
This commit is contained in:
2026-09-14 22:29:33 +02:00
parent 6169e32ca5
commit 5346efcd10
34 changed files with 636 additions and 731 deletions
+7 -25
View File
@@ -1,9 +1,10 @@
import { useSyncExternalStore } from "react";
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts), plus its reactive
// wrapper. Its OWN module (imports nothing internal) so it can be shared by both `storage.ts` (which
// keys per-account localStorage on it) and `useAccountRehydrate.ts` without a circular import — the
// account id is a lower-level primitive than the storage helpers that consume it.
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Its OWN module
// (imports nothing internal) so it can be shared by `storage.ts`, which keys per-account localStorage
// on it, without a circular import — the account id is a lower-level primitive than the storage
// helpers that consume it.
//
// Deliberately NOT reactive: BootGate pins the account before any per-account state is read, and a
// switch reloads the page, so nothing mounted ever sees the id change underneath it.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
@@ -29,22 +30,3 @@ export function withActiveAccount(path: string): string {
if (id == null) return path;
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
}
// Reactive active-account. setActiveAccount/clearActiveAccount write sessionStorage — a silent op that
// triggers no React render — so this tab's per-account state (sort/filter/view/layout, all keyed by the
// account) would never re-read once the account is finally known. That bites exactly once, on first
// load: the provider tree above <App> mounts with NO account, then <App> pins it after /api/me resolves.
// The account setters call notifyActiveAccountChange() so useActiveAccountId subscribers can re-hydrate.
const accountListeners = new Set<() => void>();
function subscribeActiveAccount(cb: () => void): () => void {
accountListeners.add(cb);
return () => accountListeners.delete(cb);
}
export function notifyActiveAccountChange(): void {
for (const cb of [...accountListeners]) cb();
}
/** The current tab's active account id, as reactive state — re-renders when it is (re)pinned. The
* snapshot is a primitive (number | null), so useSyncExternalStore's Object.is check is stable. */
export function useActiveAccountId(): number | null {
return useSyncExternalStore(subscribeActiveAccount, activeAccountId, activeAccountId);
}
-1
View File
@@ -19,7 +19,6 @@ vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" }
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
notifyActiveAccountChange: vi.fn(),
}));
import {
+1 -4
View File
@@ -6,7 +6,7 @@ import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../activeAccount";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
@@ -268,8 +268,6 @@ export function setActiveAccount(id: number): void {
} catch {
/* ignore */
}
// Let per-account UI state re-hydrate now the account is known (see notifyActiveAccountChange).
notifyActiveAccountChange();
}
export function clearActiveAccount(): void {
try {
@@ -277,7 +275,6 @@ export function clearActiveAccount(): void {
} catch {
/* ignore */
}
notifyActiveAccountChange();
}
export async function req<T = unknown>(
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
//
// lazyWithPreload: once preloaded, a page renders synchronously — no Suspense fallback, which on a module
// switch is a "Loading…" flash in place of the page. And a page that mounted through the lazy path is
// not remounted (its state kept) once the module is loaded.
import { describe, it, expect, afterEach } from "vitest";
import { Suspense, useState, type ComponentType } from "react";
import { render, screen, act, fireEvent, cleanup } from "@testing-library/react";
import { lazyWithPreload } from "./lazyWithPreload";
function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>count {n}</button>;
}
const module = (): Promise<{ default: ComponentType<object> }> =>
Promise.resolve({ default: Counter });
const inSuspense = (C: ComponentType) => (
<Suspense fallback={<span>fallback</span>}>
<C />
</Suspense>
);
afterEach(cleanup);
describe("lazyWithPreload", () => {
it("renders a preloaded page on its first render, without the fallback", async () => {
const Page = lazyWithPreload(module);
await Page.preload();
render(inSuspense(Page));
// Synchronously after render — no act/await, so a suspension would still be showing its fallback.
expect(screen.queryByText("fallback")).toBeNull();
expect(screen.getByText("count 0")).toBeTruthy();
});
it("without a preload it suspends once, then renders", async () => {
const Page = lazyWithPreload(module);
render(inSuspense(Page));
expect(screen.getByText("fallback")).toBeTruthy();
expect(await screen.findByText("count 0")).toBeTruthy();
});
it("a page mounted through the lazy path keeps its state across later renders", async () => {
// Mounted into a boundary that is ALREADY showing content — how a module switch mounts a page.
const Page = lazyWithPreload(module);
const shell = (show: boolean, tick: number) => (
<Suspense fallback={<span>fallback</span>}>
<i>shell {tick}</i>
{show && <Page />}
</Suspense>
);
const { rerender } = render(shell(false, 0));
rerender(shell(true, 0));
fireEvent.click(await screen.findByText("count 0")); // the module has loaded by now
rerender(shell(true, 1)); // a later render of the same mount
expect(screen.getByText("count 1")).toBeTruthy(); // not remounted as the loaded component
});
it("retries a preload that failed", async () => {
let calls = 0;
const Page = lazyWithPreload(() =>
++calls === 1
? Promise.reject<{ default: ComponentType<object> }>(new Error("offline"))
: module(),
);
await expect(Page.preload()).rejects.toThrow("offline");
await act(() => Page.preload());
expect(calls).toBe(2);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { createElement, lazy, type ComponentType } from "react";
// A route-level lazy component whose code can be fetched BEFORE its first render.
//
// React.lazy alone suspends on every first render, even when the chunk is already in the browser: the
// module promise settles a tick later, so the Suspense fallback is committed and painted for a frame or
// several. On a module switch that is a "Loading…" flash in place of the page, on every first visit.
// Here, once `preload()` has resolved, the component renders the loaded module synchronously and never
// suspends at all.
//
// Choosing per render (`loaded ?? Lazy`) cannot swap a mounted page's component type and remount it:
// a render that suspends on `Lazy` is never committed (React discards a suspended new mount), and
// `loaded` is set before `Lazy`'s own promise resolves — so every committed render of a mount sees the
// loaded module. Measured, not assumed: the state test in lazyWithPreload.dom.test.tsx stayed green
// with a per-mount pin removed, in a boundary that was already showing content.
export type PreloadableComponent<P extends object> = ComponentType<P> & {
/** Fetch the component's code now. Idempotent; a failed fetch is retried by the next call. */
preload: () => Promise<void>;
};
export function lazyWithPreload<P extends object>(
factory: () => Promise<{ default: ComponentType<P> }>,
): PreloadableComponent<P> {
let loaded: ComponentType<P> | null = null;
let pending: Promise<void> | null = null;
const preload = () =>
(pending ??= factory().then(
(m) => {
loaded = m.default;
},
(err: unknown) => {
pending = null;
throw err;
},
));
const Lazy = lazy(() => preload().then(() => ({ default: loaded as ComponentType<P> })));
function Preloadable(props: P) {
return createElement(loaded ?? (Lazy as unknown as ComponentType<P>), props);
}
return Object.assign(Preloadable, { preload });
}
+12 -20
View File
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { activeAccountId } from "./activeAccount";
import { useAccountRehydrate } from "./useAccountRehydrate";
// 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
@@ -16,15 +15,13 @@ export const LS = {
filters: "siftlode.filters",
page: "siftlode.page",
// E24: 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. It has to be
// localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands —
// a landing page decided after the first paint would be a visible jump.
// 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 tab's
// account is pinned — so the landing is decided right before the first paint, never corrected after.
startOnHome: "siftlode.startOnHome",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// established tab paints your view straight away. A brand-new tab still starts on the default
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
// the per-account key can't be read.
// 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",
// 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
@@ -72,7 +69,7 @@ export const LS = {
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The active-account primitive (activeAccountId / useActiveAccountId / notify) lives in ./activeAccount
// The active-account primitive (activeAccountId) lives in ./activeAccount
// so it can be shared without a cycle; the helpers below key localStorage on it.
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
@@ -154,24 +151,21 @@ export function writeJSON(key: string, value: unknown): void {
/** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
* same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
* can be used before an async-loaded option list is known, keeping hook order stable. Consumers
* usually mount only once signed in (account known), but re-read on the account-pin transition too so
* one that mounts before the pin (a fresh tab's first paint) still restores. */
* can be used before an async-loaded option list is known, keeping hook order stable. Read once, at
* mount: BootGate mounts every consumer after the account is pinned. */
export function useAccountPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
// Stable setter (base + markDirty are both stable) so a provider can build a stable action on top
// of it — e.g. ChannelsProvider's setView, which must not change identity on every tab switch.
// Stable setter (base is stable) so a provider can build a stable action on top of it — e.g.
// ChannelsProvider's setView, which must not change identity on every tab switch.
const set = useCallback(
(next: string) => {
markDirty();
setValue(next);
setAccountRaw(base, next);
},
[base, markDirty],
[base],
);
return [value, set];
}
@@ -184,17 +178,15 @@ export function useAccountPersistedObject<T extends object>(
defaults: T,
): [T, (patch: Partial<T>) => void] {
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
const patch = useCallback(
(p: Partial<T>) => {
markDirty();
setValue((prev) => {
const next = { ...prev, ...p };
writeAccount(base, next);
return next;
});
},
[base, markDirty],
[base],
);
return [value, patch];
}
@@ -1,45 +0,0 @@
// @vitest-environment jsdom
//
// useAccountRehydrate re-reads per-account state when the tab's account is pinned after mount (first
// load). useActiveAccountId is mocked with a plain variable so the transition can be driven by hand.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
let account: number | null = null;
vi.mock("./activeAccount", () => ({
useActiveAccountId: () => account,
}));
import { useAccountRehydrate } from "./useAccountRehydrate";
describe("useAccountRehydrate", () => {
beforeEach(() => {
account = null;
});
it("runs rehydrate when the account transitions from null to a value", () => {
const rehydrate = vi.fn();
const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
expect(rehydrate).not.toHaveBeenCalled(); // mount with no account: no transition
account = 1;
act(() => rerender());
expect(rehydrate).toHaveBeenCalledTimes(1);
});
it("does not run when the account id is already known and stable", () => {
account = 1;
const rehydrate = vi.fn();
const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
act(() => rerender());
expect(rehydrate).not.toHaveBeenCalled();
});
it("skips rehydrate when marked dirty before the account pins (no clobber of a user edit)", () => {
const rehydrate = vi.fn();
const { result, rerender } = renderHook(() => useAccountRehydrate(rehydrate));
act(() => result.current()); // markDirty — the owning setter ran pre-pin
account = 1;
act(() => rerender());
expect(rehydrate).not.toHaveBeenCalled();
});
});
-39
View File
@@ -1,39 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { useActiveAccountId } from "./activeAccount";
// Re-read per-account persisted state when this tab's active account becomes known (or changes). The
// provider tree above <App> mounts before the account is pinned, so its useState initializers seed from
// an unknown account (empty defaults); once <App> pins it, `rehydrate` runs to load the real stored
// value. Guarded so it never clobbers a value the user set in the brief pre-pin window: the returned
// `markDirty` is called by the owning setter, and a dirty state skips the re-hydrate.
//
// Keyed on the account id ONLY — the user's own writes go through setAccountRaw, which does not change
// the account id, so this effect can't loop on them.
//
// This is the localStorage-only counterpart of the `useEffect([me?.id])` server-prefs adopt pattern in
// FeedFiltersProvider / FeedViewProvider / PrefsProvider (and App's shell prefs): those re-load from the
// SERVER when the account query resolves; this re-loads from localStorage when the account is pinned.
// A provider needs one or the other — not both — to survive a fresh tab's first paint.
export function useAccountRehydrate(rehydrate: () => void): () => void {
const accountId = useActiveAccountId();
const dirty = useRef(false);
const prev = useRef(accountId);
// Latest closure in a ref so the effect depends on the account id alone (a fresh `rehydrate` each
// render must not re-fire it), while still calling the current logic.
const fn = useRef(rehydrate);
fn.current = rehydrate;
useEffect(() => {
if (accountId === prev.current) return; // mount, and renders with no real transition
prev.current = accountId;
if (!dirty.current) fn.current();
// Reset per account: `dirty` means "the user edited under THIS account". Today a real account
// switch full-reloads (fresh instance), but clearing it here keeps the guard correct even if a
// future switch stops reloading — the next account starts clean rather than inheriting dirtiness.
dirty.current = false;
}, [accountId]);
return useCallback(() => {
dirty.current = true;
}, []);
}
+3 -48
View File
@@ -6,26 +6,19 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
const { store, ctl } = vi.hoisted(() => ({
store: new Map<string, string>(),
ctl: { account: 1 as number | null },
}));
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
vi.mock("./storage", () => ({
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
getAccountRaw: (k: string) => store.get(k) ?? null,
setAccountRaw: (k: string, v: string) => {
if (ctl.account != null) store.set(k, v);
store.set(k, v);
},
}));
vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
import { useFilterState } from "./useFilterState";
describe("useFilterState", () => {
beforeEach(() => {
store.clear();
ctl.account = 1;
});
it("hydrates to an empty map when storage is empty", () => {
@@ -77,42 +70,4 @@ describe("useFilterState", () => {
// A seed that returns null falls back to an empty map.
expect(renderHook(() => useFilterState("k3", () => null)).result.current[0]).toEqual({});
});
// The account-pin re-hydrate (via useAccountRehydrate) integrated into useFilterState.
it("re-hydrates the stored map once the account is pinned after mount", () => {
ctl.account = null; // fresh tab: no account at mount
store.set("k", JSON.stringify({ sync: "fully", tags: ["7"] }));
const { result, rerender } = renderHook(() => useFilterState("k"));
expect(result.current[0]).toEqual({}); // mount read with no account → empty
act(() => {
ctl.account = 1; // <App> pins the account
rerender();
});
expect(result.current[0]).toEqual({ sync: "fully", tags: ["7"] });
});
it("applies the migrate seed on the account-pin re-hydrate when the key is still empty", () => {
ctl.account = null;
// Mirrors migrateSyncChip: reads an account-scoped legacy key, so it yields null until pinned.
const migrate = () => (ctl.account == null ? null : { sync: "needsFull" });
const { result, rerender } = renderHook(() => useFilterState("k", migrate));
expect(result.current[0]).toEqual({}); // migrate can't read the legacy key with no account yet
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ sync: "needsFull" }); // migrate carries the legacy value forward
});
it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
ctl.account = null;
store.set("k", JSON.stringify({ sync: "fully" })); // the stored value
const { result, rerender } = renderHook(() => useFilterState("k"));
act(() => result.current[1]({ sync: "needsFull" })); // user edits before the pin
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ sync: "needsFull" }); // the edit survives, not "fully"
});
});
+4 -10
View File
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { getAccountRaw, setAccountRaw } from "./storage";
import { useAccountRehydrate } from "./useAccountRehydrate";
import type { FilterMap } from "./tableColumns";
// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the
@@ -14,22 +13,17 @@ export function useFilterState(
// flash); it stays idempotent because the seed persists as soon as the user touches the filters.
migrate?: () => FilterMap | null,
): [FilterMap, (next: FilterMap) => void] {
// Read the account's stored map, or the migrate seed when its key is still empty. Shared by the
// initial mount AND the account-pin re-hydrate, so first-load migration runs on whichever fires with
// the account known (on a fresh tab the account is null at mount → the re-hydrate does the real read).
const read = useCallback((): FilterMap => {
// The account's stored map, or the migrate seed when its key is still empty.
const [filters, setFilters] = useState<FilterMap>(() => {
const raw = getAccountRaw(storageBase);
return raw ? hydrate(raw) : (migrate?.() ?? {});
}, [storageBase, migrate]);
const [filters, setFilters] = useState<FilterMap>(read);
const markDirty = useAccountRehydrate(() => setFilters(read()));
});
const set = useCallback(
(next: FilterMap) => {
markDirty();
setFilters(next);
setAccountRaw(storageBase, JSON.stringify(next));
},
[storageBase, markDirty],
[storageBase],
);
return [filters, set];
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type Me } from "./api";
// The signed-in account, read from the qk.me() query cache. App owns the FETCHING query (gated on
// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the
// The signed-in account, read from the qk.me() query cache. BootGate owns the FETCHING query (gated on
// setup state) and App renders modules only once it has resolved to a real user — so anywhere inside the
// authenticated module tree the cache is always populated. `enabled:false` makes this a passive
// observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache
// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook
+2 -15
View File
@@ -2,10 +2,10 @@
//
// 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
// debounced writes, 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 { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
@@ -125,19 +125,6 @@ describe("usePref", () => {
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());
+12 -34
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api } from "./api";
import type { Me } from "./api/account";
@@ -14,24 +14,19 @@ 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 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.
// 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.
//
// 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
// 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, keyed on the account, 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, and seed synchronously from it on
* first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
/** 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. */
@@ -70,8 +65,8 @@ export function useSavePref(): (patch: Record<string, unknown>) => void {
);
}
/** 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. */
/** 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,
@@ -79,8 +74,6 @@ export function usePref<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.
@@ -141,20 +134,5 @@ export function usePref<T>(
[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];
}
+3 -35
View File
@@ -6,26 +6,19 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
const { store, ctl } = vi.hoisted(() => ({
store: new Map<string, string>(),
ctl: { account: 1 as number | null },
}));
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
vi.mock("./storage", () => ({
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
getAccountRaw: (k: string) => store.get(k) ?? null,
setAccountRaw: (k: string, v: string) => {
if (ctl.account != null) store.set(k, v);
store.set(k, v);
},
}));
vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
import { useSortState } from "./useSortState";
describe("useSortState", () => {
beforeEach(() => {
store.clear();
ctl.account = 1;
});
it("hydrates to the fallback when storage is empty", () => {
@@ -62,29 +55,4 @@ describe("useSortState", () => {
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toEqual({ key: "anything", dir: "desc" });
});
// The account-pin re-hydrate (via useAccountRehydrate) integrated into useSortState.
it("re-hydrates the stored sort once the account is pinned after mount", () => {
ctl.account = null; // fresh tab: no account at mount
store.set("k", JSON.stringify({ key: "views", dir: "desc" }));
const { result, rerender } = renderHook(() => useSortState("k"));
expect(result.current[0]).toBeNull(); // mount read with no account → default
act(() => {
ctl.account = 1; // <App> pins the account
rerender();
});
expect(result.current[0]).toEqual({ key: "views", dir: "desc" });
});
it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
ctl.account = null;
store.set("k", JSON.stringify({ key: "views", dir: "desc" })); // the stored value
const { result, rerender } = renderHook(() => useSortState("k"));
act(() => result.current[1]({ key: "name", dir: "asc" })); // user edits before the pin
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ key: "name", dir: "asc" }); // the edit survives, not "views"
});
});
+1 -7
View File
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { getAccountRaw, setAccountRaw } from "./storage";
import { useAccountRehydrate } from "./useAccountRehydrate";
import { hydrateSort, type SortOption, type SortState } from "./columnSort";
// The per-account persisted sort state, hydrated + healed against the current options — the plumbing
@@ -14,17 +13,12 @@ export function useSortState(
const [sort, setSort] = useState<SortState>(() =>
hydrateSort(getAccountRaw(storageBase), options, fallback),
);
// When the account is pinned after mount (first load), re-read the stored sort for that account.
const markDirty = useAccountRehydrate(() =>
setSort(hydrateSort(getAccountRaw(storageBase), options, fallback)),
);
const set = useCallback(
(next: SortState) => {
markDirty();
setSort(next);
setAccountRaw(storageBase, JSON.stringify(next));
},
[storageBase, markDirty],
[storageBase],
);
return [sort, set];
}