refactor(frontend): one account per browser, its local state under plain keys

The per-tab account pin, the X-Siftlode-Account header and ?account= go: the session cookie alone
says who is asking. localStorage keys lose their account suffix; the browser records whose state it
holds (siftlode.owner), BootGate hands it to the account the server answered as, and a different
account starts from a clean slate (the public /watch keys stay). The first boot moves the account's
old per-account copies onto the plain keys and drops other accounts' copies. Until the claim, and
after another tab claimed the browser for someone else, the account helpers read and write nothing.

The account panel lists no other accounts: "Add another account" becomes "Sign in as someone else"
(Google's account chooser; the pick replaces this account, a cancel keeps it), shown where Google
sign-in is configured.
This commit is contained in:
2026-10-20 15:29:41 +02:00
parent 6645caa157
commit d6dada7fe5
51 changed files with 672 additions and 559 deletions
+2 -2
View File
@@ -81,8 +81,8 @@ boundary is one-way and **lint-enforced**, not just described: a platform compon
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
`lib/api/*` slices over `core.req<T>`.
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
prefs; API through `lib/api/*` slices over `core.req<T>`.
**Not wanted** (reviewed and rejected as over-engineering for this size): microservices, event bus,
CQRS, a repository/DI framework, a router library, a generic CRUD-router factory or base serializer.
+5 -4
View File
@@ -49,13 +49,14 @@ async function signIn(page: Page, email: string, path: string): Promise<void> {
// would intercept every click — the test accounts have no YouTube connection, so it auto-opens),
// and opt the account out of starting on Home. A bare URL lands on Home, and the feed, channel
// and player flows this net locks all open "/" expecting the feed; the opt-out is the one
// Settings → Appearance → Startup writes. Both flags are per-account:
// `siftlode.onboarding.dismissed.<id>`, `siftlode.startOnHome.<id>`.
// Settings → Appearance → Startup writes. Both flags are the account's local state, held for the
// account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData).
await page.goto("/");
await page.evaluate((id: number) => {
localStorage.setItem("siftlode.owner", String(id));
localStorage.setItem("siftlode.lang", "en");
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
localStorage.setItem(`siftlode.startOnHome.${id}`, "0");
localStorage.setItem("siftlode.onboarding.dismissed", "1");
localStorage.setItem("siftlode.startOnHome", "0");
}, me.id);
mkdirSync(dirname(path), { recursive: true });
await page.context().storageState({ path });
+2 -2
View File
@@ -270,8 +270,8 @@ export default function App() {
clearParams("link");
}, [t, setPage, refetchMe]);
// A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
// cancelled at Google's consent screen. The backend only emits this for a live session, so a
// A Google OAuth flow started while already signed in (signing in as someone else, or a Google
// connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a
// neutral toast is the right feedback — the pre-auth login banner would never render here.
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
const oauthAnswered = useRef(false);
+41 -24
View File
@@ -1,10 +1,9 @@
// @vitest-environment jsdom
//
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no
// pinned account in sessionStorage) built the app for "no account" first — so with Settings →
// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and
// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the
// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right.
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab built
// the app for "no account" first — so with Settings → Appearance → Startup turned off,
// NavigationProvider's initializer could not read the opt-out and landed on Home, mirrored ?page=home
// into the address bar, and then re-read its own mirror once the account was known, staying on Home.
//
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
@@ -17,7 +16,6 @@ import BootGate from "./BootGate";
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation } from "./navigationContext";
import { api, type Me } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import i18n from "../i18n";
@@ -53,10 +51,17 @@ const account = (over: Partial<Me> = {}) =>
({ id: 5, preferences: {}, plex_enabled: true, ...over }) as Me;
let savePrefs: MockInstance<typeof api.savePrefs>;
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
// Whose local state the browser holds (storage.ts claimLocalData).
const owner = () => localStorage.getItem("siftlode.owner");
// The browser already holds account 5's state, as on any visit after the first.
const heldBy5 = (entries: Record<string, string>) => {
localStorage.setItem("siftlode.owner", "5");
for (const [k, v] of Object.entries(entries)) localStorage.setItem(k, v);
};
beforeEach(async () => {
localStorage.clear();
sessionStorage.clear(); // a NEW tab: nothing pinned yet
sessionStorage.clear(); // a NEW tab
window.history.replaceState(null, "", "/");
renders.length = 0;
mounts = 0;
@@ -72,15 +77,14 @@ afterEach(() => {
describe("BootGate", () => {
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "plex");
heldBy5({ "siftlode.startOnHome": "0", "siftlode.page": "plex" });
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("plex");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
expect(new URLSearchParams(window.location.search).get("page")).toBe("plex");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
expect(owner()).toBe("5");
});
it("renders the app in the account's language from its first render", async () => {
@@ -120,12 +124,12 @@ describe("BootGate", () => {
expect(renders).toHaveLength(0);
});
it("signed out: mounts the app (App shows the landing) without pinning an account", async () => {
it("signed out: mounts the app (App shows the landing) without claiming the local state", async () => {
vi.spyOn(api, "me").mockResolvedValue(null);
mount();
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
expect(owner()).toBeNull();
});
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
@@ -135,7 +139,7 @@ describe("BootGate", () => {
await screen.findByTestId("app");
expect(me).not.toHaveBeenCalled();
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
expect(owner()).toBeNull();
});
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
@@ -148,7 +152,7 @@ describe("BootGate", () => {
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(renders.length).toBeGreaterThan(0);
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account
expect(owner()).toBe("5"); // it mounted for the account
});
it("an unanswered first account probe is an error with a retry", async () => {
@@ -157,7 +161,7 @@ describe("BootGate", () => {
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
expect(owner()).toBe("5");
});
it("a background refetch that fails keeps the app mounted, as it was", async () => {
@@ -175,17 +179,30 @@ describe("BootGate", () => {
expect(mounts).toBe(1);
});
it("a pin naming another account than the server answered as is corrected before the app reads it", async () => {
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
it("an account signing in where another one's state is held never reads it, not for one render", async () => {
// Account 9 used this browser last, and left it on Plex with Startup off.
localStorage.setItem("siftlode.owner", "9");
localStorage.setItem("siftlode.startOnHome", "0");
localStorage.setItem("siftlode.page", "plex");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("home");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "home"));
expect(owner()).toBe("5");
expect(localStorage.getItem("siftlode.page")).not.toBe("plex");
});
it("an account's state left under the old per-account keys is the one it boots on", async () => {
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "plex");
localStorage.setItem("siftlode.page.9", "files");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("plex");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "plex"));
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
expect(localStorage.getItem("siftlode.page.9")).toBeNull();
});
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
@@ -201,20 +218,20 @@ describe("BootGate", () => {
await waitFor(() => expect(reload).toHaveBeenCalled());
});
it("an identity change reloads without moving the pin first", async () => {
// The tree being torn down still runs its unmount saves before the page goes; with the pin already
// moved they would land under the NEW account's local keys (a player volume still pending, say).
it("an identity change reloads without handing the local state over first", async () => {
// The tree being torn down still runs its unmount saves before the page goes; with the state
// already handed over they would land in the NEW account's (a player volume still pending, say).
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6
me.mockResolvedValue(account({ id: 6 })); // another tab signed this browser in as 6
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
await waitFor(() => expect(reload).toHaveBeenCalled());
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
expect(owner()).toBe("5");
});
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
+16 -19
View File
@@ -1,8 +1,9 @@
import { useEffectEvent, useLayoutEffect, useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
import { api } from "../lib/api";
import { log } from "../lib/log";
import { claimLocalData } from "../lib/storage";
import { saveLanguage } from "../lib/saveLanguage";
import { qk } from "../lib/queryKeys";
import i18n, { isSupported, setLanguage } from "../i18n";
@@ -14,17 +15,16 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
// patched each piece once <App> pinned the account. Every patch was a visible jump at best, and at
// patched each piece once <App> knew the account. Every patch was a visible jump at best, and at
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
// the provider tree — whose initializers are then simply right, with nothing to re-read.
//
// The per-account initializers read the tab's PIN (sessionStorage), not `me`, so the pin is made to
// match the account the server answered as before they run. And the tree is only ever built for one
// identity: if a mounted tab's identity changes without a reload (its session ended, or the account it
// was pinned to was signed out in another tab and the server now answers as the browser's default),
// the tab reloads, like every account switch does (NavSidebar) — which also drops the previous
// The per-account initializers read the browser's local state, which holds one account's: it is handed
// to the account the server answered as (claimLocalData) before they run. And the tree is only ever
// built for one identity: if a mounted tab's identity changes without a reload (its session ended, or
// another tab signed this browser in as someone else), the tab reloads — which also drops the previous
// account's query cache, whose keys are not per account.
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared
@@ -76,13 +76,10 @@ export default function BootGate({ children }: { children: ReactNode }) {
: (me?.id ?? null);
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
// before the browser paints: the frame that first shows the app already has the account pinned.
// • Pin this tab to the account the server answered as. Without the pin, a tab riding the
// session's default account would silently swap identity when ANOTHER tab changes that default
// (adds or switches an account); once pinned it sends its own account header. The server honours
// that header only for an account still signed in to this browser, so a pin naming one that is
// not is corrected here — otherwise the tab would run as the default account on the server while
// reading and writing the gone account's local state.
// before the browser paints: the frame that first shows the app already reads the account's state.
// • Hand the browser's local state to the account the server answered as: a different account
// than the one whose state the browser holds starts from a clean slate, so it never reads (or
// writes over) the previous one's filters, history or positions.
// • Adopt the account's language, which follows the account across devices — the local copy only
// knows what this browser last showed. An account with none stored yet (the setup wizard's admin,
// an account older than the stamp sign-up gives) stores the language it is shown in: the server
@@ -98,16 +95,16 @@ export default function BootGate({ children }: { children: ReactNode }) {
if (identity === undefined) return;
if (readyFor !== undefined) {
// Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account —
// see the header. The pin stays as it is: the tree being torn down still runs its unmount saves
// before the page goes, and with the pin moved they would land under the new account's local
// keys. The reloaded page's first probe corrects it. Comparing rather than merely testing for
// see the header. The local state is not handed over here: the tree being torn down still runs
// its unmount saves before the page goes, and they belong to the account it was built for. The
// reloaded page's claim settles it. Comparing rather than merely testing for
// "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op
// instead of a reload.
if (readyFor !== identity) location.reload();
return;
}
const account = adoptedMe();
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
if (account) claimLocalData(account.id);
const lang = account?.preferences?.language;
if (typeof lang === "string" && isSupported(lang)) {
// eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale.
@@ -121,7 +118,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
}),
);
}
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the local state is claimed and the language applied, and both are writes to things outside React (storage, i18n). A layout effect so the frame that first shows the app already has them.
setReadyFor(identity);
}, [identity, readyFor, qc]);
+2 -2
View File
@@ -5,7 +5,7 @@ import { qk } from "../lib/queryKeys";
import { invalidateSubscription } from "../lib/subscriptionCache";
import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS } from "../lib/storage";
import { LS, ownKey } from "../lib/storage";
import { formatCountOrDash } from "../lib/format";
import { sortRows } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
@@ -234,7 +234,7 @@ export default function ChannelDiscovery({
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
persistKey={ownKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="both"
controlsInBand
controlsBandClassName="px-4 max-w-384 mx-auto"
+2 -2
View File
@@ -16,7 +16,7 @@ import ExcludedChannelList from "./ExcludedChannelList";
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { LS, ownKey } from "../lib/storage";
import { buildChannelColumns, applySyncFilter, tagFilterChips } from "./channelColumns";
import { notify } from "../lib/notifications";
import { Badge } from "./ui/Badge";
@@ -824,7 +824,7 @@ export default function Channels() {
rows={syncedChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
persistKey={ownKey(LS.channelsTable) ?? undefined}
// Folded, the status / pager row leaves the band; the list's own end keeps it.
controlsPosition={fold.folded ? "bottom" : "both"}
controlsLeading={statusChips}
@@ -4,7 +4,7 @@
// and a genuine browser Back (popstate carrying `_chTab`) restores + re-persists it. This wiring was
// re-routed through useAccountPersistedState, so it's exercised here rather than only by hand E2E.
//
// Uses REAL storage (jsdom localStorage/sessionStorage) with the tab's account pinned to 1, so
// Uses REAL storage (jsdom localStorage/sessionStorage) with account 1's local state claimed, so
// useAccountPersistedState / useSortState / useFilterState all resolve their account-scoped keys; only
// navigationContext (the sole runtime dependency) is mocked, pinned to the channels page.
import { describe, it, expect, beforeEach, vi } from "vitest";
@@ -18,16 +18,17 @@ vi.mock("./navigationContext", () => ({
import { ChannelsProvider } from "./ChannelsProvider";
import { useChannels, useChannelsActions } from "./channelsContext";
import { claimLocalData } from "../lib/storage";
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelsProvider>{children}</ChannelsProvider>
);
const render = () => renderHook(() => ({ ...useChannels(), ...useChannelsActions() }), { wrapper });
const VIEW_KEY = "siftlode.channelsView.1"; // account-scoped (accountKey suffixes the pinned id)
const VIEW_KEY = "siftlode.channelsView"; // the account's (claimed in beforeEach)
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // pin the tab's account
claimLocalData(1); // the browser holds account 1's state
// Reset BOTH the history state and the query string: setView only owns an entry on the channels page,
// and the URL mirror writes ctab/csort/cf — a stale key left by a prior test would be read back by
// this provider's mount-time URL seed and change the starting tab.
@@ -107,10 +108,10 @@ describe("ChannelsProvider — shareable URL", () => {
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
// persisting the seed overwrote the account's real stored sort + filter map.
localStorage.setItem(
"siftlode.channelSort.1",
"siftlode.channelSort",
JSON.stringify({ key: "subscribers", dir: "desc" }),
);
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
localStorage.setItem("siftlode.channelsFilterMap", JSON.stringify({ sync: "needsFull" }));
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
const { result } = render();
@@ -120,11 +121,11 @@ describe("ChannelsProvider — shareable URL", () => {
// ...but the omitted sort + filters keep the account's real stored values, in state AND storage.
expect(result.current.sort).toEqual({ key: "subscribers", dir: "desc" });
expect(result.current.filters).toEqual({ sync: "needsFull" });
expect(JSON.parse(localStorage.getItem("siftlode.channelSort.1")!)).toEqual({
expect(JSON.parse(localStorage.getItem("siftlode.channelSort")!)).toEqual({
key: "subscribers",
dir: "desc",
});
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap.1")!)).toEqual({
expect(JSON.parse(localStorage.getItem("siftlode.channelsFilterMap")!)).toEqual({
sync: "needsFull",
});
});
@@ -5,30 +5,31 @@
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
import { describe, it, expect, beforeEach } from "vitest";
import { loadAccountFilters } from "./feedFiltersInit";
import { accountKey, LS } from "../lib/storage";
import { claimLocalData, LS } from "../lib/storage";
const ID = 7;
beforeEach(() => localStorage.clear());
beforeEach(() => {
localStorage.clear();
claimLocalData(7);
});
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" }));
const f = loadAccountFilters(ID);
localStorage.setItem(LS.filters, JSON.stringify({ q: "hi", tagMode: "and" }));
const f = loadAccountFilters();
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
expect(f.q).toBe("hi"); // other fields preserved through the merge
});
it("leaves tagModes at defaults when nothing is stored", () => {
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
expect(loadAccountFilters().tagModes).toEqual({ language: "or", topic: "or", other: "or" });
});
it("preserves an already-migrated per-category tagModes blob", () => {
localStorage.setItem(
accountKey(LS.filters, ID)!,
LS.filters,
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
);
expect(loadAccountFilters(ID).tagModes).toEqual({
expect(loadAccountFilters().tagModes).toEqual({
language: "and",
topic: "or",
other: "and",
@@ -8,9 +8,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "./NavigationProvider";
import { FeedFiltersProvider } from "./FeedFiltersProvider";
import { useFeedFilters } from "./feedFiltersContext";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import type { Me } from "../lib/api";
import { claimLocalData } from "../lib/storage";
const scopes: string[] = [];
function Probe() {
@@ -34,7 +34,7 @@ function mount(me: Partial<Me>) {
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
claimLocalData(5);
scopes.length = 0;
window.history.replaceState(null, "", "/");
});
@@ -53,6 +53,6 @@ describe("FeedFiltersProvider seed", () => {
window.history.replaceState(null, "", "/?scope=all");
mount({});
expect(scopes[0]).toBe("all");
expect(JSON.parse(localStorage.getItem("siftlode.filters.5") ?? "{}").scope).toBe("all");
expect(JSON.parse(localStorage.getItem("siftlode.filters") ?? "{}").scope).toBe("all");
});
});
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { getActiveAccount, type FeedFilters } from "../lib/api";
import { type FeedFilters } from "../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, writeJSON } from "../lib/storage";
import { ownKey, writeJSON } from "../lib/storage";
import { logFilterChange } from "../lib/filterLog";
import { useNavigation } from "./navigationContext";
import {
@@ -29,12 +29,12 @@ import { DEFAULT_FILTERS, FILTERS_KEY, loadAccountFilters } from "./feedFiltersI
function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
return loadAccountFilters(getActiveAccount());
return loadAccountFilters();
}
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
// pinned, so this is the account's own view from the first render.
// claimed, so this is the account's own view from the first render.
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
// once at mount — such filters win over the account's stored view and are persisted to the account.
@@ -45,8 +45,8 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const setFilters = useCallback((next: FeedFilters) => {
setFiltersState(next);
logFilterChange("feed", next, next.q);
// Persist under THIS tab's active account so filters don't bleed between accounts.
const key = accountKey(FILTERS_KEY, getActiveAccount());
// Persist as the account's own view.
const key = ownKey(FILTERS_KEY);
if (key) writeJSON(key, next);
}, []);
+2 -2
View File
@@ -16,7 +16,7 @@ import { formatBytes, formatDateTime, relativeTime } from "../lib/format";
import { focusNavItem } from "../lib/listNav";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { LS, accountKey } from "../lib/storage";
import { LS, ownKey } from "../lib/storage";
import { useCardPager } from "../lib/useCardPager";
import { useDebounced } from "../lib/useDebounced";
import { useLiveQuery } from "../lib/useLiveQuery";
@@ -628,7 +628,7 @@ export default function Files() {
rows={rows}
columns={columns}
rowKey={(f) => String(f.id)}
persistKey={accountKey(LS.filesTable) ?? undefined}
persistKey={ownKey(LS.filesTable) ?? undefined}
pageSize={50}
keyboardNav
fixedLayout
@@ -2,7 +2,7 @@
//
// FilesProvider's arrival at one file: `openFile` (a notice's "Open") and a `?item=` link both make a
// pending focus that the Files page spends once it has looked; what it found is held until the page
// is left. Real storage with the tab's account pinned; only the navigation is mocked, its page
// is left. Real storage, claimed for the account; only the navigation is mocked, its page
// switchable.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
@@ -16,6 +16,7 @@ vi.mock("./navigationContext", () => ({
import { FilesProvider } from "./FilesProvider";
import { ALL_FILES, useFiles, useFilesActions } from "./filesContext";
import { claimLocalData } from "../lib/storage";
const wrapper = ({ children }: { children: ReactNode }) => (
<FilesProvider>{children}</FilesProvider>
@@ -24,7 +25,7 @@ const render = () => renderHook(() => ({ ...useFiles(), ...useFilesActions() }),
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1");
claimLocalData(1);
window.history.replaceState(null, "", "/");
nav.page = "files";
nav.setPage.mockClear();
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
//
// The account panel of the rail: one account per browser session, so it lists no other accounts. It
// offers "Sign in as someone else" (straight to Google's account chooser, whose pick replaces this
// account) where the instance has Google sign-in, and "Sign out", which ends the session.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import i18n from "../i18n";
import { api, type Me } from "../lib/api";
import NavSidebar from "./NavSidebar";
vi.mock("./navigationContext", () => ({
useNavigation: () => ({ page: "feed" }),
useNavigationActions: () => ({ setPage: vi.fn() }),
}));
vi.mock("./prefsContext", () => ({ usePrefs: () => ({ railLabels: false }) }));
const me = (over: Partial<Me> = {}) =>
({
id: 5,
email: "me@example.com",
display_name: "Me",
role: "user",
avatar_url: null,
google_email: null,
google_enabled: true,
plex_enabled: false,
preferences: {},
...over,
}) as unknown as Me;
const row = (panel: HTMLElement, text: string) =>
[...panel.querySelectorAll("button")].find((b) => b.textContent === text)!;
let href = "";
const reload = vi.fn();
async function mount(account: Me) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<NavSidebar me={account} onOpenAbout={() => {}} onChangeLanguage={() => {}} language="en" />
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: /^Me · / }));
// Positioned by floating-ui, which never settles in jsdom: the panel stays `visibility: hidden`.
return vi.waitFor(() => {
const panel = document.querySelector<HTMLElement>('[role="dialog"][aria-label="Me"]');
if (!panel) throw new Error("the account panel did not open");
return panel;
});
}
beforeEach(async () => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
await i18n.changeLanguage("en");
href = "";
reload.mockReset();
vi.stubGlobal("location", {
...window.location,
reload,
set href(v: string) {
href = v;
},
get href() {
return href;
},
});
vi.spyOn(api, "notificationUnreadCount").mockResolvedValue({ count: 0 } as never);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("the account panel", () => {
it("offers signing in as someone else, which goes straight to Google's account chooser", async () => {
const panel = await mount(me());
const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim());
expect(rows).toEqual(["Sign in as someone else", "Sign out"]);
fireEvent.click(row(panel, "Sign in as someone else"));
expect(href).toBe("/auth/login");
});
it("offers only signing out where the instance has no Google sign-in", async () => {
const panel = await mount(me({ google_enabled: false }));
const rows = [...panel.querySelectorAll("button")].map((b) => b.textContent?.trim());
expect(rows).toEqual(["Sign out"]);
});
it("signing out ends the session, then reloads onto the sign-in page", async () => {
const logout = vi.spyOn(api, "logout").mockResolvedValue({ ok: true });
const panel = await mount(me());
fireEvent.click(row(panel, "Sign out"));
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
expect(logout).toHaveBeenCalledOnce();
});
});
+12 -63
View File
@@ -1,12 +1,10 @@
import { useState, useSyncExternalStore } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react";
import { ArrowLeftRight, Info, LogOut, Settings, Shield, type LucideIcon } from "lucide-react";
import { api, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account";
import { pinOnNextPage } from "../lib/activeAccount";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
@@ -59,8 +57,8 @@ export default function NavSidebar({
// keyboard focus (a rest-to-show flyout via Tooltip), so the names are one glance away without the
// rail ever growing.
const [acctOpen, setAcctOpen] = useState(false);
// The account panel holds mixed content with its own tab order (profile, account switches, two
// actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
// The account panel holds mixed content with its own tab order (the profile, then its actions),
// so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose
// backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through
// to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its
// hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now
@@ -76,41 +74,15 @@ export default function NavSidebar({
const { setReference: setAcctAnchor } = acctPop.refs;
async function logout() {
// Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
// drops this tab's override and reloads onto whatever account remains the default.
// Ends this browser's session, then reloads onto the sign-in page.
try {
await api.logout();
} catch {
/* clear locally and reload regardless */
/* reload regardless: the page then shows whatever the server says */
}
pinOnNextPage(null);
location.reload();
}
// Accounts that have signed in on this browser (loaded only while the popover is open).
const accountsQuery = useQuery({
queryKey: qk.accounts(),
queryFn: api.accounts,
enabled: acctOpen,
});
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
function switchTo(id: number) {
// Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
// identity. No server round-trip — req() sends the account header (validated against the
// browser wallet server-side), so the cookie's default account is left untouched. The pin moves with
// the reload, not before it: the page going away still unloads as the account it was built for.
pinOnNextPage(id);
// Drop the previous account's in-module sub-view/overlay before the reload (which would
// otherwise preserve history.state across the swap). Otherwise the new account lands on a
// stale sub-view that belongs to the OLD identity. Start at the module root instead.
const st = { ...(window.history.state || {}) };
delete st._sub;
delete st._ov;
window.history.replaceState(st, "");
location.reload(); // cleanest way to reload all per-user state for the new account
}
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the
// tab is hidden); coexists with the client-side transient bell at the bottom of the rail.
const unreadQuery = useLiveQuery(qk.notifUnread(), api.notificationUnreadCount, {
@@ -270,44 +242,21 @@ export default function NavSidebar({
{t("header.account.admin")}
</div>
)}
{otherAccounts.length > 0 && (
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
{otherAccounts.map((a) => (
<Pressable
key={a.id}
onClick={() => switchTo(a.id)}
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition touch-target"
>
<AvatarImg
src={a.avatar_url}
fallback={a.display_name ?? a.email}
className="w-6 h-6 rounded-full text-3xs shrink-0"
/>
<span className="min-w-0">
<span className="block text-[0.8125rem] text-fg truncate">
{displayName(a)}
</span>
<span className="block text-2xs text-muted truncate">{a.email}</span>
</span>
</Pressable>
))}
</div>
)}
<div className="mt-2 pt-2 border-t border-border flex flex-col">
{/* Straight to Google's account chooser: the account picked there takes this one's
place in the browser (one account per session), and a cancel there leaves this
one signed in. A password account signs in from the sign-in page, after Sign out. */}
{me.google_enabled && (
<Pressable
onClick={() => {
// Adding an account switches THIS tab to it: drop the tab's pin so that on
// return from Google it adopts the freshly-added account (the new default)
// and pins that. Other tabs keep their own pinned account.
pinOnNextPage(null);
window.location.href = "/auth/login";
}}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
>
<UserPlus className="w-4 h-4" />
{t("header.account.addAccount")}
<ArrowLeftRight className="w-4 h-4" />
{t("header.account.signInAsOther")}
</Pressable>
)}
<Pressable
onClick={logout}
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
@@ -31,7 +31,7 @@ import { useBackToClose } from "../lib/history";
import { PageHeading } from "./ui/Headings";
import { qk } from "../lib/queryKeys";
import { LS, setAccountRaw } from "../lib/storage";
import { claimLocalData, LS, setAccountRaw } from "../lib/storage";
import { PAGE_KEY, START_ON_HOME } from "./navigationInit";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
@@ -82,7 +82,7 @@ beforeEach(() => {
// An admin on a Plex-enabled instance: every module is open to it, so no gate moves a page here.
qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true });
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
claimLocalData(1); // the account's page memory
window.history.replaceState({ sfPage: "feed" }, "", "/");
});
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
@@ -23,8 +23,8 @@ vi.mock("react-i18next", async (importOriginal) => {
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { pushLayer, removeLayer } from "../lib/layerStack";
import { claimLocalData } from "../lib/storage";
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
@@ -87,7 +87,7 @@ beforeAll(() => {
let play: ReturnType<typeof vi.fn>;
let pause: ReturnType<typeof vi.fn>;
beforeEach(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
claimLocalData(7);
play = vi.fn(function (this: Media) {
this._paused = false;
this.dispatchEvent(new Event("play"));
@@ -1034,7 +1034,7 @@ describe("PlexPlayer — clip mode", () => {
});
const prefs = (p: object) =>
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p }));
const infoOverlay = () => document.querySelector("[class*='bg-black/70']");
it("Escape closes what is over the picture first, then the crop and the panel", async () => {
@@ -50,7 +50,7 @@ vi.mock("hls.js", async (importOriginal) => {
import Hls from "hls.js";
import PlexPlayer from "./PlexPlayer";
import { api, type PlexItemDetail } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { claimLocalData } from "../lib/storage";
type Media = HTMLMediaElement & { _t?: number };
@@ -91,7 +91,7 @@ beforeAll(() => {
});
beforeEach(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
claimLocalData(7);
hlsInstances.length = 0;
});
@@ -21,7 +21,7 @@ import PlexPlayer from "./PlexPlayer";
import { api, HttpError, type PlexItemDetail } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { claimLocalData } from "../lib/storage";
type Media = HTMLMediaElement & { _t?: number };
@@ -75,8 +75,8 @@ beforeAll(() => {
beforeEach(() => {
// A signed-in account whose saved subtitle language is English (prefs are per account).
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "7");
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en" }));
claimLocalData(7);
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en" }));
});
afterEach(() => {
@@ -310,7 +310,7 @@ const startsAsked = (id: string) =>
describe("leaving an item that was played through", () => {
const prefs = (p: object) =>
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
localStorage.setItem("siftlode.plexPlayerPrefs", JSON.stringify({ subLang: "en", ...p }));
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
@@ -9,7 +9,7 @@ import { render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "./NavigationProvider";
import { PlexProvider } from "./PlexProvider";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { claimLocalData } from "../lib/storage";
function mount() {
render(
@@ -25,7 +25,7 @@ function mount() {
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
claimLocalData(5);
});
afterEach(() => {
cleanup();
@@ -37,9 +37,9 @@ describe("PlexProvider shared link", () => {
window.history.replaceState(null, "", "/?page=plex&pscope=movie");
mount();
expect(localStorage.getItem("siftlode.plexLibrary.5")).toBe("movie");
expect(localStorage.getItem("siftlode.plexFilters.5")).toBeNull();
expect(localStorage.getItem("siftlode.plexSort.5")).toBeNull();
expect(localStorage.getItem("siftlode.plexShow.5")).toBeNull();
expect(localStorage.getItem("siftlode.plexLibrary")).toBe("movie");
expect(localStorage.getItem("siftlode.plexFilters")).toBeNull();
expect(localStorage.getItem("siftlode.plexSort")).toBeNull();
expect(localStorage.getItem("siftlode.plexShow")).toBeNull();
});
});
@@ -10,8 +10,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PrefsProvider } from "./PrefsProvider";
import { usePrefs } from "./prefsContext";
import { api } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { accountKey, LS } from "../lib/storage";
import { claimLocalData, LS } from "../lib/storage";
import { qk } from "../lib/queryKeys";
const seen: { perf: boolean; hints: boolean }[] = [];
@@ -23,7 +22,7 @@ function Probe() {
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
claimLocalData(5);
seen.length = 0;
vi.useFakeTimers();
});
@@ -35,8 +34,8 @@ afterEach(() => {
describe("PrefsProvider seed", () => {
it("renders the account's server prefs from the first render, and does not save them back", () => {
localStorage.setItem(accountKey(LS.perfMode, 5)!, "0"); // this browser last had it off
localStorage.setItem(accountKey(LS.hints, 5)!, "1");
localStorage.setItem(LS.perfMode, "0"); // this browser last had it off
localStorage.setItem(LS.hints, "1");
const save = vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 5, preferences: { performanceMode: true, hints: false } });
@@ -45,8 +45,7 @@ vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider } from "./SavedViewsProvider";
import { useSavedViews } from "./savedViewsContext";
import { api, type SavedView } from "../lib/api";
import { LS, accountKey } from "../lib/storage";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { claimLocalData, LS } from "../lib/storage";
import { qk } from "../lib/queryKeys";
const ID = 7;
@@ -95,7 +94,7 @@ async function mountResolved(views: SavedView[]) {
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID));
claimLocalData(ID);
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
// case would suppress seeding in the others.
window.history.replaceState({}, "", "/");
@@ -118,7 +117,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
});
it("feed: does NOT seed when the account already has stored filters", async () => {
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
});
@@ -177,7 +176,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
it("plex: does NOT seed when plex filters are already stored", async () => {
h.page = "plex";
localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] }));
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
expect(h.setPlexFilters).not.toHaveBeenCalled();
});
@@ -5,7 +5,6 @@ import { qk } from "../lib/queryKeys";
import {
api,
coercePlexFilters,
getActiveAccount,
plexFiltersSignature,
type FeedFilters,
type PlexFilters,
@@ -14,7 +13,7 @@ import {
import { filtersToParams, hasFilterParams } from "../lib/urlState";
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
import { notifyActionError } from "../lib/actionErrors";
import { accountKey, LS } from "../lib/storage";
import { LS, ownKey } from "../lib/storage";
import { useConfirm } from "./confirmContext";
import { useNavigation } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
@@ -55,7 +54,7 @@ const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePle
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
// report "stored" so a default never seeds blind over an indeterminate state.
function storedFiltersPresent(lsKey: string): boolean {
const key = accountKey(lsKey, getActiveAccount());
const key = ownKey(lsKey);
if (!key) return true;
try {
return localStorage.getItem(key) != null;
@@ -8,8 +8,7 @@ import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-libr
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
import { api, type FeedFilters, type ManagedChannel } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { accountKey, LS } from "../lib/storage";
import { claimLocalData, LS } from "../lib/storage";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import ChannelPicker from "./ChannelPicker";
@@ -28,7 +27,7 @@ const filters: FeedFilters = {
...clearedFilters({ q: "" } as FeedFilters),
channelIds: [KNOWN, NEW],
};
const namesKey = () => accountKey(LS.channelNames, 5)!;
const namesKey = () => LS.channelNames;
let resolveChannels: (c: ManagedChannel[]) => void;
let qc: QueryClient;
@@ -37,7 +36,7 @@ const wrapper = ({ children }: { children: ReactNode }) =>
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
claimLocalData(5);
localStorage.setItem(namesKey(), JSON.stringify({ [KNOWN]: "Remembered name" }));
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.spyOn(api, "tags").mockResolvedValue([] as never);
+1 -4
View File
@@ -61,10 +61,7 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str
// rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for
// the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed).
const remote = job.asset?.thumbnail_url ?? null;
// `job.poster_url` only SIGNALS a poster exists; the actual <img> src must carry ?account= (see
// api.posterImageUrl). A plain <img> can't send the X-Siftlode-Account header, so without it the
// poster resolves to the session-default account and 404s for a per-tab account's download — which,
// now that the poster is the primary image, reads as a blank Library thumbnail.
// `job.poster_url` only SIGNALS a poster exists; the <img> src is api.posterImageUrl.
const poster = job.poster_url ? api.posterImageUrl(job.id) : null;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
+4 -4
View File
@@ -1,5 +1,5 @@
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters } from "../lib/api";
import { accountKey, LS, readJSON } from "../lib/storage";
import { LS, ownKey, readJSON } from "../lib/storage";
// The feed filters' defaults and the per-account localStorage seed, split out of FeedFiltersProvider
// (like navigationInit / playlistsInit) so that file exports only components (Fast Refresh).
@@ -23,16 +23,16 @@ export const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]
export const FILTERS_KEY = LS.filters;
// This account's own persisted filters (per-account keys — never another account's). No account
// This account's own persisted filters (see ownKey). No account
// (signed out) → defaults.
export function loadAccountFilters(id: number | null): FeedFilters {
export function loadAccountFilters(): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
const fKey = accountKey(FILTERS_KEY, id);
const fKey = ownKey(FILTERS_KEY);
let hasStored = false;
try {
hasStored = !!fKey && localStorage.getItem(fKey) != null;
@@ -32,7 +32,7 @@ const PLAIN: Record<string, [number, string]> = {
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
],
"LanguageSwitcher.tsx": [1, "an option row"],
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
"NavSidebar.tsx": [3, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"],
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
+1 -2
View File
@@ -30,8 +30,7 @@
"settings": "Settings",
"about": "About",
"signOut": "Sign out",
"addAccount": "Add another account",
"switchTo": "Switch to {{name}}",
"signInAsOther": "Sign in as someone else",
"googleAddress": "Google account: {{email}}",
"activity": "Activity"
},
+1 -2
View File
@@ -30,8 +30,7 @@
"settings": "Beállítások",
"about": "Névjegy",
"signOut": "Kijelentkezés",
"addAccount": "Másik fiók hozzáadása",
"switchTo": "Váltás erre: {{name}}",
"signInAsOther": "Belépés más fiókkal",
"googleAddress": "Google-fiók: {{email}}",
"activity": "Napló"
},
@@ -1,89 +0,0 @@
// @vitest-environment jsdom
//
// The notification and hints stores read their per-account localStorage when they are imported. In a new
// tab (bookmark, typed URL: no pin in sessionStorage) that is before BootGate pins the account, so they
// read as nobody's; they must read again on the pin. The notification store used to keep its empty,
// nobody's history, and the tab's first notice replaced the account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const entry = (id: number, message: string) => ({
id,
level: "info",
message,
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
});
const storedMessages = (account: number) =>
(
JSON.parse(localStorage.getItem(`siftlode.notifications.${account}`) ?? "[]") as {
message: string;
}[]
).map((n) => n.message);
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
// vi.resetModules, so each case still gets fresh module instances, only faster.
beforeAll(async () => {
await import("./notifications");
await import("./hints");
await import("./api/core");
}, 60_000);
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // both stores load at import: each test needs a fresh one
});
describe("stores read at import, in a tab not yet pinned", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once the tab is pinned", async () => {
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications");
const { setActiveAccount } = await import("./api/core");
notifications.notify({ message: "before the pin" }); // e.g. a failed boot probe
setActiveAccount(5);
notifications.notify({ message: "after the pin" });
expect(storedMessages(5)).toEqual(["older", "before the pin", "after the pin"]);
expect(notifications.getNotifSettings().durationMs).toBe(0);
});
it("read the account's hints choice once the tab is pinned", async () => {
localStorage.setItem("siftlode.hints.5", "0");
const hints = await import("./hints");
const { setActiveAccount } = await import("./api/core");
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
setActiveAccount(5);
expect(hints.hintsEnabled()).toBe(false);
});
});
describe("a pinned tab pinned to another account", { timeout: CASE_BUDGET }, () => {
it("leaves the previous account's notices with that account", async () => {
// BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as.
sessionStorage.setItem("siftlode.activeAccount", "4");
localStorage.setItem("siftlode.notifications.4", JSON.stringify([entry(7, "four's")]));
localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(8, "five's")]));
const notifications = await import("./notifications");
const { setActiveAccount } = await import("./api/core");
notifications.notify({ message: "as four" });
setActiveAccount(5);
notifications.notify({ message: "as five" });
expect(storedMessages(5)).toEqual(["five's", "as five"]);
expect(storedMessages(4)).toEqual(["four's", "as four"]);
});
});
@@ -1,37 +0,0 @@
// @vitest-environment jsdom
//
// A switch or sign-out hands the tab's next page its account instead of moving the pin: the page being left
// still unloads as its own account. The players' resume beacons read the pin as they fire on the way out,
// and with the pin already moved they saved one account's playback position into the other.
import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
sessionStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "3");
vi.resetModules(); // the hand-over is taken at import: each import below is a page load
});
describe("pinOnNextPage", () => {
it("leaves the pin alone until the next page, which takes the account over once", async () => {
const leaving = await import("./activeAccount");
leaving.pinOnNextPage(7);
expect(leaving.activeAccountId()).toBe(3); // the unload still acts as 3
vi.resetModules();
const next = await import("./activeAccount");
expect(next.activeAccountId()).toBe(7);
next.pinActiveAccount(9); // BootGate corrects it
vi.resetModules(); // and an F5 keeps the correction
expect((await import("./activeAccount")).activeAccountId()).toBe(9);
});
it("hands over no account at all, so the next page adopts the session's default", async () => {
const leaving = await import("./activeAccount");
leaving.pinOnNextPage(null);
expect(leaving.activeAccountId()).toBe(3);
vi.resetModules();
expect((await import("./activeAccount")).activeAccountId()).toBeNull();
});
});
-78
View File
@@ -1,78 +0,0 @@
// 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 provider mounts, and a switch reloads
// the page (handing the new pin over through `pinOnNextPage`), so nothing mounted ever sees the id change
// underneath it. The one exception is state read at
// module import (the notification and hints stores), which in a new tab runs before the pin: it reads
// again through `onAccountPinned`.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
// Re-readers for module-level per-account state, run each time the tab is pinned.
const pinListeners = new Set<() => void>();
export function onAccountPinned(reload: () => void): void {
pinListeners.add(reload);
}
export function pinActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
pinListeners.forEach((reload) => reload());
}
// The account the tab's NEXT page is pinned to, handed over by a switch or sign-out that is about to leave.
// The pin itself stays until the page is gone: the unload still runs as the account the page was built for
// (the players' resume beacons read the pin as they fire), and with the pin already moved it would save one
// account's position into the other.
const NEXT_ACCOUNT_KEY = "siftlode.nextAccount";
/** Pin the tab's next page to `id` — or to none, so it adopts the session's default account. */
export function pinOnNextPage(id: number | null): void {
try {
sessionStorage.setItem(NEXT_ACCOUNT_KEY, id == null ? "" : String(id));
} catch {
/* ignore */
}
}
// Taken over at import, before anything reads the pin.
try {
const next = sessionStorage.getItem(NEXT_ACCOUNT_KEY);
if (next != null) {
sessionStorage.removeItem(NEXT_ACCOUNT_KEY);
if (next) sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, next);
else sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
}
} catch {
/* ignore */
}
export function activeAccountId(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
// Append `?account=<active>` to a top-level navigation URL (SEC-11). XHRs carry the active account in
// the X-Siftlode-Account header, but a full-page navigation (window.location) can't, so the OAuth
// link/upgrade GETs would resolve to the SESSION DEFAULT account instead of the tab's active one — the
// identity/token would then land on the wrong account. This threads the active account through the URL
// so resolved_user_id picks it. No-op when there's no distinct active account pinned yet.
export function withActiveAccount(path: string): string {
const id = activeAccountId();
if (id == null) return path;
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
}
+2 -2
View File
@@ -6,10 +6,10 @@
// 3. spreads each domain's method slice into the single `api` object consumers call.
// A new endpoint therefore has nowhere to land except a domain module — the interleave that
// motivated the split cannot re-grow here. (lib/api/facade.test.ts guards that invariant.)
import { getActiveAccount, HttpError, setActiveAccount, setUnauthorizedHandler } from "./api/core";
import { HttpError, setUnauthorizedHandler } from "./api/core";
// Re-export the shared HTTP machinery so consumers keep importing these from "./api" unchanged.
export { HttpError, getActiveAccount, setActiveAccount, setUnauthorizedHandler };
export { HttpError, setUnauthorizedHandler };
import { accountApi } from "./api/account";
export type * from "./api/account";
+2 -15
View File
@@ -49,14 +49,6 @@ export function displayName(a: { display_name: string | null; email: string }):
return a.display_name ?? a.email.split("@")[0]!;
}
export interface Account {
id: number;
email: string;
display_name: string | null;
avatar_url: string | null;
active: boolean;
}
export interface VersionInfo {
app_version: string;
git_sha: string;
@@ -73,14 +65,9 @@ export const accountApi = {
const r = await req<{ authenticated: boolean } & Partial<Me>>("/api/me");
return r.authenticated ? (r as Me) : null;
},
accounts: (): Promise<Account[]> => req("/api/me/accounts"),
deleteAccount: (): Promise<{ deleted: boolean }> => req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
// X-Siftlode-Account header that req() attaches).
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
req("/auth/logout", { method: "POST" }),
// Ends this browser's session.
logout: (): Promise<{ ok: boolean }> => req("/auth/logout", { method: "POST" }),
version: (): Promise<VersionInfo> => req("/api/version"),
// Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
savePrefs: (p: Record<string, unknown>) =>
+3 -8
View File
@@ -16,11 +16,6 @@ const { reportError, notify, remove, getActiveToasts } = vi.hoisted(() => ({
vi.mock("../errorDialog", () => ({ reportError }));
vi.mock("../notifications", () => ({ notify, remove, getActiveToasts }));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import { setDebugSession } from "../log";
import { downloadsApi } from "./downloads";
@@ -155,9 +150,9 @@ describe("uploadReq", () => {
});
it("lets its own identity headers win over a caller's", () => {
// Who a request is from is this layer's to state, not its caller's. Merged the other way round,
// a caller's header of the same name would silently re-attribute the upload — to another
// account, or (here) into someone else's debug session recording.
// What a request is part of is this layer's to state, not its caller's. Merged the other way
// round, a caller's header of the same name would silently re-attribute the upload into someone
// else's debug session recording.
setDebugSession("0123456789abcdef");
try {
void uploadReq("/u", body(), {
+6 -21
View File
@@ -1,12 +1,11 @@
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing,
// error, structured-error localization, the connectivity toast, the identity headers,
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
// so the runtime graph is a clean barrel → domain → core with no cycles.
import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes, formatDecimal } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { currentDebugSession, newRequestId } from "../log";
@@ -268,28 +267,16 @@ export const setupHeaders = (token: string) => ({
"X-Setup-Token": token,
});
// --- Per-tab active account --------------------------------------------------------------
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
// The debug session this tab is recording: the server tags its own lines of the request with
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
const DEBUG_SESSION_HEADER = "X-Debug-Session";
export const getActiveAccount = activeAccountId;
/** Who this request is from — the tab's account and the debug session it records into — as the
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
* cannot quietly go out without them. */
/** What this request carries besides the session cookie (which alone says who is asking) — the
* debug session it records into — as the headers every request path sends (`req`, `beacon`,
* `uploadReq`): one definition, so a path cannot quietly go out without them. */
export function identityHeaders(): Record<string, string> {
const active = getActiveAccount();
const session = currentDebugSession();
return {
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
};
return session ? { [DEBUG_SESSION_HEADER]: session } : {};
}
// Fire-and-forget POST that survives page unload (keepalive) — for every write a closing page must
@@ -315,8 +302,6 @@ export function beacon(url: string, body: Record<string, unknown>, onSettled?: (
}
}
export const setActiveAccount = pinActiveAccount;
export async function req<T = unknown>(
url: string,
opts: RequestInit = {},
@@ -398,7 +383,7 @@ export async function req<T = unknown>(
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab account header,
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the identity headers,
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
//
@@ -13,11 +13,6 @@ vi.mock("../notifications", () => ({
getActiveToasts: vi.fn((): { id: number }[] => []),
}));
vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } }));
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
pinActiveAccount: () => {},
}));
import { debugSessionsApi } from "./diagnostics";
+4 -9
View File
@@ -2,7 +2,7 @@
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
import type { LogEvent } from "../log";
import type { ServerLogRow } from "./admin";
import { beacon, getActiveAccount, identityHeaders, req } from "./core";
import { beacon, identityHeaders, req } from "./core";
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
@@ -123,12 +123,7 @@ export const debugSessionsApi = {
req(`${SESSIONS}/${id}/preview`, { method: "POST", body: JSON.stringify({ text }) }),
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
* tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a
* per-tab account's screenshot resolves to the session default and 404s. */
debugScreenshotUrl: (id: string, shotId: number): string => {
const a = getActiveAccount();
const url = `${SESSIONS}/${id}/screenshots/${shotId}`;
return a != null ? `${url}?account=${a}` : url;
},
/** Where an `<img>` loads a screenshot from. */
debugScreenshotUrl: (id: string, shotId: number): string =>
`${SESSIONS}/${id}/screenshots/${shotId}`,
};
+6 -18
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { beacon, req, getActiveAccount } from "./core";
// reference each other, and its methods only touch the shared `req`/`beacon` from core.
import { beacon, req } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -314,22 +314,10 @@ export const downloadsApi = {
},
onSettled,
),
// Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
storyboardImageUrl: (id: number): string => {
const a = getActiveAccount();
return a != null
? `/api/downloads/${id}/storyboard.jpg?account=${a}`
: `/api/downloads/${id}/storyboard.jpg`;
},
// The locally-generated poster (an <img> src → direct nav, so same ?account= wallet gate). Without
// it the poster resolves to the session-default account and 404s for a per-tab account's download —
// which reads as a blank Library thumbnail once the poster is the card's primary image.
posterImageUrl: (id: number): string => {
const a = getActiveAccount();
return a != null
? `/api/downloads/${id}/poster.jpg?account=${a}`
: `/api/downloads/${id}/poster.jpg`;
},
// Filmstrip sprite (an <img>/background src).
storyboardImageUrl: (id: number): string => `/api/downloads/${id}/storyboard.jpg`,
// The locally-generated poster (an <img> src).
posterImageUrl: (id: number): string => `/api/downloads/${id}/poster.jpg`,
// admin
adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
+5 -14
View File
@@ -1,6 +1,6 @@
// The Files library: a user's done files, the folders they sit in, and the file itself
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
import { getActiveAccount, req, uploadReq } from "./core";
import { req, uploadReq } from "./core";
import type { DownloadJob } from "./downloads";
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user;
@@ -144,19 +144,10 @@ export const filesApi = {
headers,
});
},
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active account via
// ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
// file resolves to the session-default account and 404s for a per-tab account's file.
fileUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
},
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source, so it
// carries the account the same way.
previewUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/preview?account=${a}` : `/api/files/${id}/preview`;
},
// The file itself, for a plain <a> navigation.
fileUrl: (id: number): string => `/api/files/${id}/file`,
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source.
previewUrl: (id: number): string => `/api/files/${id}/preview`,
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
previewText: async (id: number): Promise<FilePreviewText> => {
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
//
// The notification and hints stores read the account's localStorage when they are imported, which is
// before BootGate knows the account, so they read as nobody's; they must read again on the claim. The
// notification store used to keep its empty, nobody's history, and the tab's first notice replaced the
// account's stored history with it.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const entry = (id: number, message: string) => ({
id,
level: "info",
message,
ts: 1,
read: true,
dismissed: true,
transient: false,
repeat: 1,
requiresInteraction: false,
});
const storedMessages = () =>
(
JSON.parse(localStorage.getItem("siftlode.notifications") ?? "[]") as {
message: string;
}[]
).map((n) => n.message);
// Warm the transform cache once (see verifyEntry.dom.test.tsx): the first cold import costs ~0.8 s
// alone and outran the 5 s case timeout under a full coverage run. Transformed code outlives
// vi.resetModules, so each case still gets fresh module instances, only faster.
beforeAll(async () => {
await import("./notifications");
await import("./hints");
await import("./storage");
}, 60_000);
// Warm or not, each case still loads and evaluates the modules it imports afresh after its module
// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's
// workers share a busy machine.
const CASE_BUDGET = 30_000;
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // both stores load at import: each test needs a fresh one
});
describe("stores read at import, before the account is known", { timeout: CASE_BUDGET }, () => {
it("keep the account's notification history and settings once it is claimed", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "older")]));
localStorage.setItem("siftlode.notifSettings", JSON.stringify({ durationMs: 0 }));
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
notifications.notify({ message: "before the claim" }); // e.g. a failed boot probe
claimLocalData(5);
notifications.notify({ message: "after the claim" });
expect(storedMessages()).toEqual(["older", "before the claim", "after the claim"]);
expect(notifications.getNotifSettings().durationMs).toBe(0);
});
it("read the account's hints choice once it is claimed", async () => {
localStorage.setItem("siftlode.owner", "5");
localStorage.setItem("siftlode.hints", "0");
const hints = await import("./hints");
const { claimLocalData } = await import("./storage");
expect(hints.hintsEnabled()).toBe(true); // nobody's: the default
claimLocalData(5);
expect(hints.hintsEnabled()).toBe(false);
});
});
describe("a browser that held another account's state", { timeout: CASE_BUDGET }, () => {
it("starts the new account's notices from an empty history", async () => {
localStorage.setItem("siftlode.owner", "4");
localStorage.setItem("siftlode.notifications", JSON.stringify([entry(7, "four's")]));
const notifications = await import("./notifications");
const { claimLocalData } = await import("./storage");
claimLocalData(5);
notifications.notify({ message: "as five" });
expect(storedMessages()).toEqual(["as five"]);
});
});
+3 -4
View File
@@ -1,17 +1,16 @@
// App-wide "hints" toggle: when on, Tooltip components reveal short explanatory
// captions on hover so the UI is somewhat self-documenting for new users. Experienced
// users can turn it off in Settings. Persisted to localStorage (+ server preferences).
import { onAccountPinned } from "./activeAccount";
import { createStore } from "./store";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
import { getAccountRaw, LS, onLocalDataClaimed, setAccountRaw } from "./storage";
function load(): boolean {
return getAccountRaw(LS.hints) !== "0"; // default ON (also when the account isn't known yet)
}
const store = createStore<boolean>(load());
// Read at import — in a new tab, before BootGate pins the account — so read again once it is pinned.
onAccountPinned(() => store.set(load()));
// Read at import — before BootGate knows the account — so read again once it is claimed.
onLocalDataClaimed(() => store.set(load()));
export const hintsEnabled = store.get;
export const subscribeHints = store.subscribe;
+151
View File
@@ -0,0 +1,151 @@
// @vitest-environment jsdom
//
// A browser holds ONE account's local state: the keys carry no account id, and the browser records
// whose they are. These pin the boot-time claim — the move of the old per-account keys onto the plain
// ones, the wipe when another account starts here — and the guard that keeps a tab from reading or
// writing them before its account is known, or after another tab has claimed them for someone else.
import { beforeEach, describe, expect, it, vi } from "vitest";
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.resetModules(); // the claim lives in module state: each case starts unclaimed
});
const storage = () => import("./storage");
const keys = (s: Storage) => Object.keys(s).sort();
describe("claiming the browser's local data", () => {
it("moves the account's old per-account copies onto the plain keys and drops other accounts' copies", async () => {
localStorage.setItem("siftlode.filters.1", '{"q":"mine"}');
localStorage.setItem("siftlode.filters.7", '{"q":"theirs"}');
localStorage.setItem("siftlode.onboarding.dismissed.1", "1");
localStorage.setItem("siftlode.notifications.7", "[]");
sessionStorage.setItem("siftlode.settingsTab.1", "account");
sessionStorage.setItem("siftlode.settingsTab.7", "plex");
const { claimLocalData, LS } = await storage();
claimLocalData(1);
expect(localStorage.getItem(LS.filters)).toBe('{"q":"mine"}');
expect(localStorage.getItem(LS.onboardingDismissed)).toBe("1");
expect(sessionStorage.getItem(LS.settingsTab)).toBe("account");
expect(keys(localStorage)).toEqual(
["siftlode.filters", "siftlode.onboarding.dismissed", "siftlode.owner"].sort(),
);
expect(keys(sessionStorage)).toEqual(["siftlode.settingsTab"]);
});
it("an old per-account copy wins over a plain key already there", async () => {
localStorage.setItem("siftlode.hints", "1");
localStorage.setItem("siftlode.hints.1", "0");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.hints")).toBe("0");
});
it("leaves a key that only looks like an account copy alone", async () => {
localStorage.setItem("siftlode.somethingElse.3", "x");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.somethingElse.3")).toBe("x");
});
it("drops the per-tab account pin the multi-account build kept", async () => {
sessionStorage.setItem("siftlode.activeAccount", "1");
sessionStorage.setItem("siftlode.nextAccount", "7");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(sessionStorage.getItem("siftlode.activeAccount")).toBeNull();
expect(sessionStorage.getItem("siftlode.nextAccount")).toBeNull();
});
it("another account starting here wipes the previous one's state, but not the browser's own", async () => {
localStorage.setItem("siftlode.owner", "7");
localStorage.setItem("siftlode.filters", '{"q":"theirs"}');
localStorage.setItem("siftlode.theme", "dark");
localStorage.setItem("siftlode.watchResume", '{"tok":{"at":5,"t":1}}');
localStorage.setItem("siftlode.watchRepeat", "false");
localStorage.setItem("unrelated.key", "kept");
sessionStorage.setItem("siftlode.settingsTab", "plex");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(keys(localStorage)).toEqual(
["siftlode.owner", "siftlode.watchRepeat", "siftlode.watchResume", "unrelated.key"].sort(),
);
expect(localStorage.getItem("siftlode.owner")).toBe("1");
expect(keys(sessionStorage)).toEqual([]);
});
it("the same account starting again keeps everything", async () => {
localStorage.setItem("siftlode.owner", "1");
localStorage.setItem("siftlode.filters", '{"q":"mine"}');
sessionStorage.setItem("siftlode.settingsTab", "account");
const { claimLocalData } = await storage();
claimLocalData(1);
expect(localStorage.getItem("siftlode.filters")).toBe('{"q":"mine"}');
expect(sessionStorage.getItem("siftlode.settingsTab")).toBe("account");
});
it("tells the stores read at import to read again", async () => {
const { claimLocalData, onLocalDataClaimed } = await storage();
const reread = vi.fn();
onLocalDataClaimed(reread);
claimLocalData(1);
expect(reread).toHaveBeenCalledOnce();
});
});
describe("the account's local state", () => {
it("is neither read nor written before the account is known", async () => {
localStorage.setItem("siftlode.hints", "0");
const { getAccountRaw, setAccountRaw, readAccount, writeAccount, ownKey } = await storage();
expect(getAccountRaw("siftlode.hints")).toBeNull();
expect(readAccount("siftlode.filters", "fallback")).toBe("fallback");
expect(ownKey("siftlode.filters")).toBeNull();
setAccountRaw("siftlode.page", "plex");
writeAccount("siftlode.filters", { q: "x" });
expect(localStorage.getItem("siftlode.page")).toBeNull();
expect(localStorage.getItem("siftlode.filters")).toBeNull();
});
it("is read and written under the plain key once claimed", async () => {
const { claimLocalData, getAccountRaw, setAccountRaw, ownKey } = await storage();
claimLocalData(1);
setAccountRaw("siftlode.page", "plex");
expect(localStorage.getItem("siftlode.page")).toBe("plex");
expect(getAccountRaw("siftlode.page")).toBe("plex");
expect(ownKey("siftlode.filters")).toBe("siftlode.filters");
});
it("stops being touched once another tab has claimed the browser for someone else", async () => {
const { claimLocalData, getAccountRaw, setAccountRaw } = await storage();
claimLocalData(1);
setAccountRaw("siftlode.page", "plex");
// Another tab signed in as account 6: its boot wiped the state and recorded the new owner.
localStorage.clear();
localStorage.setItem("siftlode.owner", "6");
localStorage.setItem("siftlode.hints", "0"); // account 6's
setAccountRaw("siftlode.page", "feed");
expect(localStorage.getItem("siftlode.page")).toBeNull();
expect(getAccountRaw("siftlode.hints")).toBeNull();
});
});
+8 -11
View File
@@ -2,8 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
import { activeAccountId, onAccountPinned } from "./activeAccount";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
import { LS, onLocalDataClaimed, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log";
// A notification's level in the client log: what the user was TOLD is part of the story a log tells.
@@ -226,18 +225,16 @@ function emit() {
recompute();
// The history and settings above are read at import, which in a new tab is before BootGate pins its
// account — so they read as nobody's. Read them again for the account once it is pinned: otherwise the
// tab's first write would replace the account's stored history with its own empty one. A notice raised
// before the pin (a failed boot probe) belongs to no account, so it stays, after the history.
let loadedFor = activeAccountId();
onAccountPinned(() => {
const unpinned = loadedFor == null ? items : [];
loadedFor = activeAccountId();
// The history and settings above are read at import, before BootGate knows the account — so they
// read as nobody's. Read them again once the account is claimed (once per page: a different account
// reloads it): otherwise the first write would replace the account's stored history with an empty one.
// A notice raised before the claim (a failed boot probe) belongs to no account, so it stays, after the
// history.
onLocalDataClaimed(() => {
config = loadSettings();
// Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer).
const history = load().map((n) => ({ ...n, id: counter++ }));
items = [...history, ...unpinned].slice(-MAX_HISTORY);
items = [...history, ...items].slice(-MAX_HISTORY);
recompute();
listeners.forEach((l) => l());
});
+1 -1
View File
@@ -16,7 +16,7 @@ import { connectGoogle } from "./googleConnect";
import { getAccountRaw, LS, setAccountRaw } from "./storage";
const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
// Per-account (see accountKey): a fresh account should still get the onboarding nudge even if a
// 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;
+3 -4
View File
@@ -5,15 +5,14 @@
// never show a layout changed on another device.
import { beforeEach, describe, expect, it } from "vitest";
import { seedLayout } from "./panelLayout";
import { ACTIVE_ACCOUNT_KEY } from "./activeAccount";
import { accountKey, LS } from "./storage";
import { claimLocalData, LS } from "./storage";
const local = { order: [], collapsed: { genre: true }, hidden: {} };
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
localStorage.setItem(accountKey(LS.plexLayout, 5)!, JSON.stringify(local));
claimLocalData(5);
localStorage.setItem(LS.plexLayout, JSON.stringify(local));
});
describe("seedLayout", () => {
-1
View File
@@ -49,7 +49,6 @@ export const qk = {
savedViews: (...rest: unknown[]) => ["saved-views", ...rest],
// --- account / misc --- (me/myStatus/tags already declared as shared roots above)
accounts: () => ["accounts"] as const,
version: () => ["version"] as const,
// Static text bundled with the app (lib/releaseNotes), one file per language: never stale.
releaseNotes: (lang: string) => ["release-notes", lang] as const,
@@ -49,4 +49,26 @@ describe("removed features stay removed", () => {
);
expect(hits).toEqual([]);
});
// One account per browser session (2026-10-20): the per-tab account pin, the header and `?account=`
// that carried it, and the account list and switch went. A request naming an account would now
// be ignored by the server, so a pin left behind reads as working while it does nothing. Only the
// claim that clears the old pin keys (storage.ts) and its test may name them.
it("leaves no per-tab account pin, header or switch", { timeout: 30_000 }, () => {
const PIN =
/X-Siftlode-Account|activeAccount|nextAccount|[?&]account=|switchAccount|\/api\/me\/(accounts|switch)/i;
const allowed = new Set([
"frontend/src/lib/storage.ts",
"frontend/src/lib/localData.dom.test.ts",
]);
const hits = ["frontend/src", "frontend/e2e"]
.flatMap(files)
.filter((f) => !allowed.has(f) && f !== "frontend/src/lib/removedFeatures.contract.test.ts")
.flatMap((f) =>
readFileSync(new URL(f, REPO), "utf-8")
.split("\n")
.flatMap((line, i) => (PIN.test(line) ? [`${f}:${i + 1}: ${line.trim()}`] : [])),
);
expect(hits).toEqual([]);
});
});
+90 -32
View File
@@ -1,5 +1,4 @@
import { useCallback, useState } from "react";
import { activeAccountId } from "./activeAccount";
// 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
@@ -35,8 +34,9 @@ export const LS = {
page: "siftlode.page",
// 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 tab's
// account is pinned — so the landing is decided right before the first paint, never corrected after.
// `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",
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
@@ -96,43 +96,103 @@ export const LS = {
watchResume: "siftlode.watchResume",
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The active-account primitive (activeAccountId) lives in ./activeAccount
// so it can be shared without a cycle; the helpers below key localStorage on it.
// --- 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
// records whose state it holds instead (OWNER_KEY), and an account that is not the recorded one starts
// from a clean slate. The account is known only once BootGate has it from the server, and `claimLocalData`
// is how BootGate hands it over; until then the helpers below read nothing and write nothing, so the
// signed-out landing, or a boot still asking, cannot touch the state of whoever signs in next.
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
* playlist, notifications, tab positions, cached prefs…) is NOT shared across the accounts signed
* into one browser — a bare shared key leaks one account's state into another (and, with per-tab
* accounts, two tabs would fight over it). Defaults to the current tab's active account; returns
* null when no account is known yet, so callers fall back to defaults / skip persistence instead
* of touching another account's data. */
export function accountKey(
base: string,
id: number | null | undefined = activeAccountId(),
): string | null {
return id != null ? `${base}.${id}` : null;
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]);
// 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 ACCOUNT_COPY = /^(.+)\.(\d+)$/;
let claimed: string | null = null;
const claimListeners = new Set<() => void>();
/** Re-read module-level state read at import (the notification and hints stores), which runs before
* BootGate knows the account. */
export function onLocalDataClaimed(reread: () => void): void {
claimListeners.add(reread);
}
/** Per-account variants of the read/write helpers: key by the current account, no-op / return the
* fallback when the account isn't known (avoids reading or writing a shared/other-account key). */
function each(store: Storage, visit: (key: string) => void): void {
for (const key of Object.keys(store)) visit(key);
}
function settleStore(store: Storage, id: string, wipe: boolean): void {
each(store, (key) => {
if (!key.startsWith("siftlode.")) return;
if (wipe && !BROWSER_KEYS.has(key)) {
store.removeItem(key);
return;
}
const [, base, owner] = ACCOUNT_COPY.exec(key) ?? [];
if (!base || !REGISTERED.has(base)) return;
const value = store.getItem(key);
if (owner === id && value != null) store.setItem(base, value);
store.removeItem(key);
});
}
/** Hand the browser's local state to the signed-in account `id` (BootGate, before the app mounts).
* Another account's state is wiped first; the old per-account copies are moved onto the plain keys. */
export function claimLocalData(id: number): void {
const me = String(id);
try {
const owner = localStorage.getItem(OWNER_KEY);
const wipe = owner != null && owner !== me;
settleStore(localStorage, me, wipe);
settleStore(sessionStorage, me, wipe);
for (const key of LEGACY_PIN_KEYS) sessionStorage.removeItem(key);
localStorage.setItem(OWNER_KEY, me);
} catch {
/* storage unavailable: nothing to settle */
}
claimed = me;
claimListeners.forEach((reread) => reread());
}
/** `base` while this tab's account owns the browser's local state, else null: before the claim, and
* after another tab has claimed it for a different account (a sign-in there replaces this one). */
export function ownKey(base: string): string | null {
if (claimed == null) return null;
try {
return localStorage.getItem(OWNER_KEY) === claimed ? base : null;
} catch {
return null;
}
}
/** The account's read/write helpers: no-op / the fallback while the state is not this tab's (see ownKey). */
export function readAccount<T>(base: string, fallback: T): T {
const k = accountKey(base);
const k = ownKey(base);
return k ? readJSON(k, fallback) : fallback;
}
export function readAccountMerged<T extends object>(base: string, defaults: T): T {
const k = accountKey(base);
const k = ownKey(base);
return k ? readMerged(k, defaults) : { ...defaults };
}
export function writeAccount(base: string, value: unknown): void {
const k = accountKey(base);
const k = ownKey(base);
if (k) writeJSON(k, value);
}
export function getAccountRaw(base: string): string | null {
const k = accountKey(base);
const k = ownKey(base);
try {
return k ? localStorage.getItem(k) : null;
} catch {
return null;
}
}
export function setAccountRaw(base: string, value: string): void {
const k = accountKey(base);
const k = ownKey(base);
if (k) {
try {
localStorage.setItem(k, value);
@@ -176,11 +236,9 @@ export function writeJSON(key: string, value: unknown): void {
// --- reactive persisted string state ----------------------------------------------------
/** 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
/** A `useState` whose string value is mirrored to the account's localStorage (see ownKey). 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. Read once, at
* mount: BootGate mounts every consumer after the account is pinned. */
* mount: BootGate mounts every consumer after the account is claimed. */
export function useAccountPersistedState(
base: string,
fallback = "",
@@ -201,13 +259,13 @@ export function useAccountPersistedState(
// --- a page's sub-tab, per browser tab ---------------------------------------------------------
// Which sub-tab a page was left on (Settings, Configuration, Statistics, Users) is kept for THIS browser
// tab only — sessionStorage, account-scoped: it survives a reload and a trip to another page, and a new
// tab only — sessionStorage, the account's (see ownKey): it survives a reload and a trip to another page, and a new
// tab opens the page on its first sub-tab (the owner's call: a position remembered forever in
// localStorage made Configuration open on "Plex" in every fresh tab, as if that were its home).
/** The sub-tab stored for this browser tab, or null. */
function getTabRaw(base: string): string | null {
const k = accountKey(base);
const k = ownKey(base);
try {
return k ? sessionStorage.getItem(k) : null;
} catch {
@@ -217,7 +275,7 @@ function getTabRaw(base: string): string | null {
/** Pre-select a page's sub-tab before navigating there (a "Review" link, the Google-link return). */
export function setTabRaw(base: string, value: string): void {
const k = accountKey(base);
const k = ownKey(base);
if (!k) return;
try {
sessionStorage.setItem(k, value);
@@ -233,7 +291,7 @@ export function useTabPersistedState(
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => {
// The position used to live in localStorage; a value left there would never be read again.
const old = accountKey(base);
const old = ownKey(base);
if (old) {
try {
localStorage.removeItem(old);
+6 -7
View File
@@ -5,11 +5,10 @@
// (sessionStorage): it survives a reload and a trip to another page, and a new tab starts on the first.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { ACTIVE_ACCOUNT_KEY } from "./activeAccount";
import { setTabRaw, useTabPersistedState } from "./storage";
import { claimLocalData, setTabRaw, useTabPersistedState } from "./storage";
beforeEach(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "1");
claimLocalData(1);
});
afterEach(() => {
sessionStorage.clear();
@@ -21,8 +20,8 @@ describe("a page's sub-tab, per browser tab", () => {
const first = renderHook(() => useTabPersistedState("siftlode.configTab", "access"));
expect(first.result.current[0]).toBe("access");
act(() => first.result.current[1]("plex"));
expect(sessionStorage.getItem("siftlode.configTab.1")).toBe("plex");
expect(localStorage.getItem("siftlode.configTab.1")).toBeNull();
expect(sessionStorage.getItem("siftlode.configTab")).toBe("plex");
expect(localStorage.getItem("siftlode.configTab")).toBeNull();
// The page mounts again in this tab (a reload, or back from another page): it is where it was.
first.unmount();
expect(
@@ -31,10 +30,10 @@ describe("a page's sub-tab, per browser tab", () => {
});
it("ignores a position left in localStorage by an older build, and clears it", () => {
localStorage.setItem("siftlode.configTab.1", "plex");
localStorage.setItem("siftlode.configTab", "plex");
const { result } = renderHook(() => useTabPersistedState("siftlode.configTab", "access"));
expect(result.current[0]).toBe("access");
expect(localStorage.getItem("siftlode.configTab.1")).toBeNull();
expect(localStorage.getItem("siftlode.configTab")).toBeNull();
});
it("a pre-selected tab is the one the page opens on", () => {
+2 -1
View File
@@ -14,13 +14,14 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { useCardPager } from "./useCardPager";
import { claimLocalData } from "./storage";
const rows = (n: number) => Array.from({ length: n }, (_, i) => i);
const KEY = "siftlode.test.cardSize";
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1");
claimLocalData(1);
});
describe("useCardPager — an out-of-range page", () => {