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.
190 lines
7.1 KiB
TypeScript
190 lines
7.1 KiB
TypeScript
// @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, 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 } 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({})),
|
|
}));
|
|
|
|
// 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);
|
|
},
|
|
writeAccount: (base: string, value: unknown) => {
|
|
store.set(base, JSON.stringify(value));
|
|
},
|
|
}));
|
|
vi.mock("./api", () => ({ api: { savePrefs, me: vi.fn() } }));
|
|
// The failure path (usePref.saveFailure.dom.test.ts) is not this file's subject; stubbed so the
|
|
// reporter's notifications → storage chain does not meet the storage mock above.
|
|
vi.mock("./actionErrors", () => ({ notifyActionError: 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" });
|
|
});
|
|
|
|
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 });
|
|
});
|
|
|
|
it("flushes a pending debounced write on unmount instead of dropping it", () => {
|
|
const qc = makeClient();
|
|
seedMe(qc, {});
|
|
const { result, unmount } = renderHook(() => usePref<number>("vol", 0, { debounceMs: 300 }), {
|
|
wrapper: wrapperFor(qc),
|
|
});
|
|
act(() => result.current[1](42));
|
|
expect(savePrefs).not.toHaveBeenCalled(); // still inside the debounce window
|
|
unmount(); // cleanup must flush the pending write, not lose it
|
|
expect(savePrefs).toHaveBeenCalledTimes(1);
|
|
expect(savePrefs).toHaveBeenCalledWith({ vol: 42 });
|
|
});
|
|
|
|
it("unmount with no pending write does not call savePrefs", () => {
|
|
const qc = makeClient();
|
|
seedMe(qc, {});
|
|
const { unmount } = renderHook(() => usePref<number>("vol", 0, { debounceMs: 300 }), {
|
|
wrapper: wrapperFor(qc),
|
|
});
|
|
unmount();
|
|
expect(savePrefs).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
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"] });
|
|
});
|
|
});
|