fix(ui): a page's sub-tab is remembered per browser tab, not forever

Settings, Configuration, Statistics and Users kept the last sub-tab in localStorage, so every
fresh tab opened Configuration on "Plex" as if that were its home. useTabPersistedState keeps it
in sessionStorage (account-scoped): a reload and a trip to another page keep it, a new tab starts
on the first sub-tab. The pre-selects (a Review link, the Google-link return) write the same store;
a value an older build left in localStorage is cleared on first read.
This commit is contained in:
2026-10-02 00:49:46 +02:00
parent be156fc52b
commit e4c80fb7bb
10 changed files with 117 additions and 15 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ import {
type PanelLayout,
} from "./lib/panelLayout";
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, setAccountRaw } from "./lib/storage";
import { LS, setTabRaw } from "./lib/storage";
import { usePref } from "./lib/usePref";
import { useVerifyEntry } from "./lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "./components/navigationContext";
@@ -249,7 +249,7 @@ export default function App() {
if (result === "ok") {
notify({ level: "success", message: t("settings.account.googleLink.linked") });
void refetchMe();
setAccountRaw(LS.settingsTab, "account");
setTabRaw(LS.settingsTab, "account");
setPage("settings");
} else {
const key = result === "conflict" || result === "mismatch" ? result : "error";
+2 -2
View File
@@ -41,7 +41,7 @@ import AccountPanel from "./AccountPanel";
import { useConfirm } from "./confirmContext";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { useAccountPersistedState } from "../lib/storage";
import { useTabPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
@@ -69,7 +69,7 @@ const ROW_OPEN =
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const [tab, setTab] = useTabPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const panel = useTabPanel();
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
+2 -2
View File
@@ -15,7 +15,7 @@ import { Switch } from "./ui/form";
import SegmentedToggle from "./ui/SegmentedToggle";
import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage";
import { LS, useTabPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
import { Button } from "./ui/Button";
import { CheckRow } from "./ui/NativeControls";
@@ -56,7 +56,7 @@ export default function ConfigPanel() {
const [reseedToken, setReseedToken] = useState(0);
// One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = useAccountPersistedState(LS.configTab);
const [tab, setTab] = useTabPersistedState(LS.configTab);
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
+2 -2
View File
@@ -10,7 +10,7 @@ import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { displayName, roleKey } from "../lib/api/account";
import { notifyActionError } from "../lib/actionErrors";
import { LS, useAccountPersistedState } from "../lib/storage";
import { LS, useAccountPersistedState, useTabPersistedState } from "../lib/storage";
import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
@@ -68,7 +68,7 @@ export default function SettingsPanel() {
const prefs = usePrefs();
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
const { openWizard } = useWizard();
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance");
const panel = useTabPanel();
// Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
+2 -2
View File
@@ -8,7 +8,7 @@ import { formatEta, formatNumber, formatUnit, quotaActionLabel } from "../lib/fo
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useWizard } from "./wizardContext";
import { LS, useAccountPersistedState } from "../lib/storage";
import { LS, useTabPersistedState } from "../lib/storage";
import Tooltip from "./ui/Tooltip";
import { LoadingState, StateMessage } from "./ui/QueryState";
import FilterChipGroup from "./ui/FilterChipGroup";
@@ -34,7 +34,7 @@ export default function Stats() {
const me = useMe();
const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
const [tabRaw, setTab] = useTabPersistedState(LS.statsTab, "overview");
// Clamp at render: only admins may land on the System tab (covers a stale stored value).
const tab: StatsTab = tabRaw === "system" && isAdmin ? "system" : "overview";
const panel = useTabPanel();
@@ -57,7 +57,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
vi.mock("./ui/Tabs", () => ({ default: () => null }));
vi.mock("../lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/storage")>()),
useAccountPersistedState: () => ["access", () => {}],
useTabPersistedState: () => ["access", () => {}],
}));
const listMock = vi.fn();
@@ -48,7 +48,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock }));
vi.mock("./ui/Tabs", () => ({ default: () => null }));
vi.mock("../lib/storage", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/storage")>()),
useAccountPersistedState: () => ["access", () => {}],
useTabPersistedState: () => ["access", () => {}],
}));
const pendingMock = vi.fn();
+3 -3
View File
@@ -1,8 +1,8 @@
import { LS, setAccountRaw } from "./storage";
import { LS, setTabRaw } from "./storage";
// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to
// pre-select that tab before navigating there (a notification's "Review" link uses it —
// useAccountPersistedState reads the key on mount, and the page mounts fresh on navigation).
// useTabPersistedState reads the key on mount, and the page mounts fresh on navigation).
//
// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab
// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull
@@ -11,5 +11,5 @@ export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab;
const ADMIN_USERS_ACCESS_TAB = "access";
export function focusAccessRequestsTab(): void {
setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
setTabRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB);
}
+57
View File
@@ -42,6 +42,8 @@ export const LS = {
channelDiscoveryCardSize: "siftlode.channelDiscoveryCardSize",
channelsTable: "siftlode.channelsTable",
channelDiscoveryTable: "siftlode.channelDiscoveryTable",
// A page's sub-tab: sessionStorage, per browser tab (useTabPersistedState), like adminUsersTab and
// statsTab below.
settingsTab: "siftlode.settingsTab",
configTab: "siftlode.configTab",
auditTable: "siftlode.auditTable",
@@ -174,6 +176,61 @@ export function useAccountPersistedState(
return [value, set];
}
// --- 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 opens the page on its first sub-tab (the owner, E31: 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);
try {
return k ? sessionStorage.getItem(k) : null;
} catch {
return 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);
if (!k) return;
try {
sessionStorage.setItem(k, value);
} catch {
/* ignore */
}
}
/** `useAccountPersistedState` for a page's sub-tab, kept per browser tab (see above). */
export function useTabPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState<string>(() => {
// Before E31 the position lived in localStorage; a value left there would never be read again.
const old = accountKey(base);
if (old) {
try {
localStorage.removeItem(old);
} catch {
/* ignore */
}
}
return getTabRaw(base) ?? fallback;
});
const set = useCallback(
(next: string) => {
setValue(next);
setTabRaw(base, next);
},
[base],
);
return [value, set];
}
/** A per-account persisted JSON OBJECT: `patch(partial)` merges + saves, unknown/missing keys fall
* back to `defaults` (via readAccountMerged) so adding a field later is safe. Used for bundles of
* related per-account settings (e.g. the Plex player prefs: volume, offsets, seek steps, sub style). */
+45
View File
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
//
// E31 (the owner): a page's sub-tab was remembered forever in localStorage, so every fresh browser tab
// opened Configuration on "Plex" as if that were its home. The position is now kept per browser tab
// (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";
beforeEach(() => {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "1");
});
afterEach(() => {
sessionStorage.clear();
localStorage.clear();
});
describe("a page's sub-tab, per browser tab", () => {
it("starts on the fallback, then keeps the picked tab for this browser tab only", () => {
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();
// The page mounts again in this tab (a reload, or back from another page): it is where it was.
first.unmount();
expect(
renderHook(() => useTabPersistedState("siftlode.configTab", "access")).result.current[0],
).toBe("plex");
});
it("ignores a position left in localStorage by an older build, and clears it", () => {
localStorage.setItem("siftlode.configTab.1", "plex");
const { result } = renderHook(() => useTabPersistedState("siftlode.configTab", "access"));
expect(result.current[0]).toBe("access");
expect(localStorage.getItem("siftlode.configTab.1")).toBeNull();
});
it("a pre-selected tab is the one the page opens on", () => {
setTabRaw("siftlode.adminUsersTab", "access");
const { result } = renderHook(() => useTabPersistedState("siftlode.adminUsersTab", "roles"));
expect(result.current[0]).toBe("access");
});
});