Fixes the 5 findings from /code-review high on the account-pin work: - Extract the reactive-account primitives (ACTIVE_ACCOUNT_KEY, activeAccountId, notifyActiveAccountChange, useActiveAccountId) into a new dependency-free lib/activeAccount.ts, breaking the storage.ts <-> useAccountRehydrate.ts import cycle. storage.ts / api/core.ts / useAccountRehydrate.ts now import from it. - NavigationProvider: the page re-hydrate now PRESERVES existing history.state markers (spread, not overwrite), and openChannel/enterYtSearch markDirty so a sub-view opened before the account pins isn't clobbered by the page override. - useAccountRehydrate: reset the dirty flag after each account transition, so a future non-reloading account switch can't skip the re-hydrate and leak the prior account's state. - Doc-link useAccountRehydrate to the pre-existing useEffect([me?.id]) server-adopt pattern. - Tests: model a mutable pinned account and add useSortState integration tests for the null->id re-hydrate + the markDirty no-clobber guard (vitest 115->117). gate-green: vite build + vitest 117 + eslint-0 + knip + prettier. Self-E2E'd on a fresh tab: still lands on Plex with Movies/In-progress/Year restored, zero console errors.
91 lines
4.1 KiB
TypeScript
91 lines
4.1 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The persistence/hydration wiring of useSortState (the pure hydrateSort/healSort logic it leans on is
|
|
// covered in columnSort.test.ts). storage is mocked with an in-memory map so the hook can be exercised
|
|
// without a logged-in account (getAccountRaw/setAccountRaw are account-keyed in the real module).
|
|
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 },
|
|
}));
|
|
vi.mock("./storage", () => ({
|
|
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
|
|
setAccountRaw: (k: string, v: string) => {
|
|
if (ctl.account != null) 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", () => {
|
|
const { result } = renderHook(() => useSortState("k"));
|
|
expect(result.current[0]).toBeNull();
|
|
const withFallback = renderHook(() => useSortState("k2", [], { key: "date", dir: "desc" }));
|
|
expect(withFallback.result.current[0]).toEqual({ key: "date", dir: "desc" });
|
|
});
|
|
|
|
it("hydrates a stored SortState on mount", () => {
|
|
store.set("k", JSON.stringify({ key: "views", dir: "desc" }));
|
|
const { result } = renderHook(() => useSortState("k"));
|
|
expect(result.current[0]).toEqual({ key: "views", dir: "desc" });
|
|
});
|
|
|
|
it("persists on set, and a fresh mount reads it back (write → reload → hydrate)", () => {
|
|
const first = renderHook(() => useSortState("k"));
|
|
act(() => first.result.current[1]({ key: "name", dir: "asc" }));
|
|
expect(first.result.current[0]).toEqual({ key: "name", dir: "asc" });
|
|
expect(store.get("k")).toBe(JSON.stringify({ key: "name", dir: "asc" }));
|
|
const second = renderHook(() => useSortState("k"));
|
|
expect(second.result.current[0]).toEqual({ key: "name", dir: "asc" });
|
|
});
|
|
|
|
it("heals a stored key that the supplied options no longer offer", () => {
|
|
store.set("k", JSON.stringify({ key: "gone", dir: "asc" }));
|
|
const opts = [{ key: "name", label: "Name" }];
|
|
const { result } = renderHook(() => useSortState("k", opts, { key: "name", dir: "asc" }));
|
|
expect(result.current[0]).toEqual({ key: "name", dir: "asc" });
|
|
});
|
|
|
|
it("keeps a stored key un-healed when no options are supplied (caller heals for display)", () => {
|
|
store.set("k", JSON.stringify({ key: "anything", dir: "desc" }));
|
|
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"
|
|
});
|
|
});
|