Files
siftlode/frontend/src/lib/useSortState.dom.test.ts
T
peter 5346efcd10 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.
2026-09-14 22:29:33 +02:00

59 lines
2.6 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";
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
vi.mock("./storage", () => ({
getAccountRaw: (k: string) => store.get(k) ?? null,
setAccountRaw: (k: string, v: string) => {
store.set(k, v);
},
}));
import { useSortState } from "./useSortState";
describe("useSortState", () => {
beforeEach(() => {
store.clear();
});
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" });
});
});