From e4c80fb7bb4c32b046f5057aa7fa6b2ddc1f588b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 00:49:46 +0200 Subject: [PATCH] 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. --- frontend/src/App.tsx | 4 +- frontend/src/components/AdminUsers.tsx | 4 +- frontend/src/components/ConfigPanel.tsx | 4 +- frontend/src/components/SettingsPanel.tsx | 4 +- frontend/src/components/Stats.tsx | 4 +- .../src/components/adminInvites.dom.test.tsx | 2 +- .../adminPendingAccess.dom.test.tsx | 2 +- frontend/src/lib/adminUsersTab.ts | 6 +- frontend/src/lib/storage.ts | 57 +++++++++++++++++++ frontend/src/lib/tabState.dom.test.ts | 45 +++++++++++++++ 10 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 frontend/src/lib/tabState.dom.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index deac0a4c..2b0db8f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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"; diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 1703aba9..7717cf5e 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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") }, diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index bc43693f..57c8d81d 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -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 diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 4fa8ee34..0feaa259 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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) diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index ad70f22d..e84ab0ff 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -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(); diff --git a/frontend/src/components/adminInvites.dom.test.tsx b/frontend/src/components/adminInvites.dom.test.tsx index 536b98cb..e7bb319a 100644 --- a/frontend/src/components/adminInvites.dom.test.tsx +++ b/frontend/src/components/adminInvites.dom.test.tsx @@ -57,7 +57,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock })); vi.mock("./ui/Tabs", () => ({ default: () => null })); vi.mock("../lib/storage", async (importOriginal) => ({ ...(await importOriginal()), - useAccountPersistedState: () => ["access", () => {}], + useTabPersistedState: () => ["access", () => {}], })); const listMock = vi.fn(); diff --git a/frontend/src/components/adminPendingAccess.dom.test.tsx b/frontend/src/components/adminPendingAccess.dom.test.tsx index 7a33d17d..2faed812 100644 --- a/frontend/src/components/adminPendingAccess.dom.test.tsx +++ b/frontend/src/components/adminPendingAccess.dom.test.tsx @@ -48,7 +48,7 @@ vi.mock("../lib/useMe", () => ({ useMe: () => meMock })); vi.mock("./ui/Tabs", () => ({ default: () => null })); vi.mock("../lib/storage", async (importOriginal) => ({ ...(await importOriginal()), - useAccountPersistedState: () => ["access", () => {}], + useTabPersistedState: () => ["access", () => {}], })); const pendingMock = vi.fn(); diff --git a/frontend/src/lib/adminUsersTab.ts b/frontend/src/lib/adminUsersTab.ts index dff16f7a..1aa799e6 100644 --- a/frontend/src/lib/adminUsersTab.ts +++ b/frontend/src/lib/adminUsersTab.ts @@ -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); } diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 264cf7a5..1a2feee7 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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(() => { + // 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). */ diff --git a/frontend/src/lib/tabState.dom.test.ts b/frontend/src/lib/tabState.dom.test.ts new file mode 100644 index 00000000..4dceea4d --- /dev/null +++ b/frontend/src/lib/tabState.dom.test.ts @@ -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"); + }); +});