From 9917d908eb1db9ab54c087880d6704e78b4d2f7c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 14 Aug 2026 02:56:55 +0200 Subject: [PATCH] fix(nav,plex,playlists): extend account-pin re-hydrate to every affected provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fresh-tab restore bug wasn't Channels-specific: any provider that mounts above and reads per-account state at useState-init hit it, since pins the tab's account only after /api/me. Audited all of them — FeedFilters/FeedView/Prefs/App already self-correct via their own me?.id server-prefs adopt effects, so the still-broken ones were the pure-localStorage providers with no server adopt: NavigationProvider (landing page), PlexProvider (scope/show/sort/filters/q), PlaylistsProvider (selected playlist). Each now adopts the shared useAccountRehydrate hook (markDirty in the setters; page uses replaceState so the restore doesn't add a phantom Back step). Also baked re-hydrate into useAccountPersistedState/Object so their consumers (persisted tabs, player prefs) are robust even if one ever mounts before the pin. Self-E2E'd on a genuinely fresh tab: it lands on Plex with Movies/In-progress/Year/search all restored on the FIRST load. --- .../src/components/NavigationProvider.tsx | 42 +++-- frontend/src/components/PlaylistsProvider.tsx | 27 ++- frontend/src/components/PlexProvider.tsx | 156 +++++++++++------- frontend/src/lib/storage.ts | 12 +- 4 files changed, 155 insertions(+), 82 deletions(-) diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 59adce81..dfbfbf71 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next"; import { isPage, readPage, type Page } from "../lib/urlState"; import { pageTitleKey } from "../lib/pageMeta"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; +import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { setNavigator } from "../lib/nav"; // Owns the app's navigation + browser-history state, lifted out of the App god component so pages @@ -103,18 +104,35 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const channelViewRef = useRef(channelView); channelViewRef.current = channelView; - const setPage = useCallback((next: Page) => { - // A channel page overlays the content column, so a nav click must close it even when the - // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). - if (next === pageRef.current && !channelViewRef.current) return; - setChannelView(null); // leave any open channel page when navigating via the rail - setPageState(next); - setAccountRaw(PAGE_KEY, next); - // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the - // app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over - // _yt/_chan markers) so each page starts at its root. - window.history.pushState({ sfPage: next }, ""); - }, []); + // The landing page is per-account (PAGE_KEY), but this provider mounts above , which pins the + // tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and + // falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry + // (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage) + // skips this if the user already navigated in the pre-pin window. + const markDirty = useAccountRehydrate(() => { + const stored = loadInitialPage(); + if (stored === pageRef.current) return; + setPageState(stored); + setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative + window.history.replaceState({ sfPage: stored }, ""); + }); + + const setPage = useCallback( + (next: Page) => { + // A channel page overlays the content column, so a nav click must close it even when the + // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). + if (next === pageRef.current && !channelViewRef.current) return; + markDirty(); + setChannelView(null); // leave any open channel page when navigating via the rail + setPageState(next); + setAccountRaw(PAGE_KEY, next); + // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the + // app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over + // _yt/_chan markers) so each page starts at its root. + window.history.pushState({ sfPage: next }, ""); + }, + [markDirty], + ); const openChannel = useCallback((id: string, name?: string) => { setChannelView({ id, name }); diff --git a/frontend/src/components/PlaylistsProvider.tsx b/frontend/src/components/PlaylistsProvider.tsx index 42c42559..25ba37d9 100644 --- a/frontend/src/components/PlaylistsProvider.tsx +++ b/frontend/src/components/PlaylistsProvider.tsx @@ -1,5 +1,6 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; +import { useAccountRehydrate } from "../lib/useAccountRehydrate"; // Owns the Playlists module's shared local state — the rail's name filter and the selected playlist // — lifted out of the App god component so the header search box, the PlaylistsRail, and the @@ -35,18 +36,28 @@ export function usePlaylistsActions(): PlaylistsActions { return useContext(ActionsContext); } +function readSelectedId(): number | null { + const s = getAccountRaw(LS.playlist); + return s ? Number(s) : null; +} + export function PlaylistsProvider({ children }: { children: ReactNode }) { const [search, setSearchState] = useState(""); - const [selectedId, setSelectedIdState] = useState(() => { - const s = getAccountRaw(LS.playlist); - return s ? Number(s) : null; - }); + const [selectedId, setSelectedIdState] = useState(readSelectedId); + + // Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab): + // this provider mounts above , which pins the account only after /api/me. + const markDirty = useAccountRehydrate(() => setSelectedIdState(readSelectedId())); const setSearch = useCallback((q: string) => setSearchState(q), []); - const setSelectedId = useCallback((id: number | null) => { - setSelectedIdState(id); - if (id != null) setAccountRaw(LS.playlist, String(id)); - }, []); + const setSelectedId = useCallback( + (id: number | null) => { + markDirty(); + setSelectedIdState(id); + if (id != null) setAccountRaw(LS.playlist, String(id)); + }, + [markDirty], + ); const state = useMemo(() => ({ search, selectedId }), [search, selectedId]); const actions = useMemo( diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx index 83b8b53f..9fe36f7f 100644 --- a/frontend/src/components/PlexProvider.tsx +++ b/frontend/src/components/PlexProvider.tsx @@ -3,6 +3,7 @@ import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; import { parseSortState, type SortState } from "../lib/columnSort"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; +import { useAccountRehydrate } from "../lib/useAccountRehydrate"; // Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort / // expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of @@ -10,8 +11,10 @@ import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; // instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts, // stable useCallback setters, one-line hooks; composed in main.tsx around ). // -// scope / show / sort / filters / q are per-account persisted (localStorage); the account switch does -// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral +// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds +// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above +// , which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload +// (an account SWITCH does full-reload, but the FIRST load does not). `playlistOpen` is an ephemeral // hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex // search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks // into (and is never greeted by a stale value from) the feed. @@ -58,45 +61,51 @@ export function usePlexActions(): PlexActions { return useContext(ActionsContext); } +// The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate. +function readPlexScope(): string { + const v = getAccountRaw(LS.plexLibrary) ?? "both"; + return ["movie", "show", "both"].includes(v) ? v : "both"; +} +const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all"; +const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}"; +const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? ""; +// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the +// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the +// upgrade rather than snapping back to the default. +function readPlexSort(): SortState { + const raw = getAccountRaw(LS.plexSort); + if (!raw) return PLEX_SORT_FALLBACK; + let wasJson = false; + try { + const parsed = parseSortState(JSON.parse(raw)); + wasJson = true; + if (parsed) return parsed; + } catch { + /* not JSON → a legacy bare-key string, handled below */ + } + // Valid JSON that wasn't a SortState is corrupt — fall back rather than treat the JSON text as a + // key. Only a non-JSON raw is a genuine legacy bare key. + if (wasJson) return PLEX_SORT_FALLBACK; + let dir: "asc" | "desc" = "asc"; + try { + if ( + (JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir === "desc" + ) + dir = "desc"; + } catch { + /* ignore */ + } + return { key: raw, dir }; +} + export function PlexProvider({ children }: { children: ReactNode }) { // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library). // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".) - const [scope, setScopeState] = useState(() => { - const v = getAccountRaw(LS.plexLibrary) ?? "both"; - return ["movie", "show", "both"].includes(v) ? v : "both"; - }); - const [show, setShowState] = useState(() => getAccountRaw(LS.plexShow) ?? "all"); - // Sort is now a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort - // plus the direction that used to live in plexFilters.sortDir — so an existing sort preference - // survives the upgrade rather than snapping back to the default. - const [sort, setSortState] = useState(() => { - const raw = getAccountRaw(LS.plexSort); - if (!raw) return PLEX_SORT_FALLBACK; - let wasJson = false; - try { - const parsed = parseSortState(JSON.parse(raw)); - wasJson = true; - if (parsed) return parsed; - } catch { - /* not JSON → a legacy bare-key string, handled below */ - } - // Valid JSON that wasn't a SortState is corrupt — fall back rather than treat the JSON text as a - // key. Only a non-JSON raw is a genuine legacy bare key. - if (wasJson) return PLEX_SORT_FALLBACK; - let dir: "asc" | "desc" = "asc"; - try { - if ( - (JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir === - "desc" - ) - dir = "desc"; - } catch { - /* ignore */ - } - return { key: raw, dir }; - }); + const [scope, setScopeState] = useState(readPlexScope); + const [show, setShowState] = useState(readPlexShow); + const [sort, setSortState] = useState(readPlexSort); // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). - const [filtersRaw, setFiltersRaw] = useState(() => getAccountRaw(LS.plexFilters) ?? "{}"); + const [filtersRaw, setFiltersRaw] = useState(readPlexFiltersRaw); const filters = useMemo(() => { try { // Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak @@ -108,30 +117,59 @@ export function PlexProvider({ children }: { children: ReactNode }) { } }, [filtersRaw]); // Survives F5 (persisted), unlike the feed search — see the scope note above. - const [q, setQState] = useState(() => getAccountRaw(LS.plexQ) ?? ""); + const [q, setQState] = useState(readPlexQ); const [playlistOpen, setPlaylistOpen] = useState(null); - const setScope = useCallback((v: string) => { - setScopeState(v); - setAccountRaw(LS.plexLibrary, v); - }, []); - const setShow = useCallback((v: string) => { - setShowState(v); - setAccountRaw(LS.plexShow, v); - }, []); - const setSort = useCallback((v: SortState) => { - setSortState(v); - setAccountRaw(LS.plexSort, JSON.stringify(v)); - }, []); - const setFilters = useCallback((f: PlexFilters) => { - const s = JSON.stringify(f); - setFiltersRaw(s); - setAccountRaw(LS.plexFilters, s); - }, []); - const setQ = useCallback((v: string) => { - setQState(v); - setAccountRaw(LS.plexQ, v); - }, []); + // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab). + const markDirty = useAccountRehydrate(() => { + setScopeState(readPlexScope()); + setShowState(readPlexShow()); + setSortState(readPlexSort()); + setFiltersRaw(readPlexFiltersRaw()); + setQState(readPlexQ()); + }); + + const setScope = useCallback( + (v: string) => { + markDirty(); + setScopeState(v); + setAccountRaw(LS.plexLibrary, v); + }, + [markDirty], + ); + const setShow = useCallback( + (v: string) => { + markDirty(); + setShowState(v); + setAccountRaw(LS.plexShow, v); + }, + [markDirty], + ); + const setSort = useCallback( + (v: SortState) => { + markDirty(); + setSortState(v); + setAccountRaw(LS.plexSort, JSON.stringify(v)); + }, + [markDirty], + ); + const setFilters = useCallback( + (f: PlexFilters) => { + markDirty(); + const s = JSON.stringify(f); + setFiltersRaw(s); + setAccountRaw(LS.plexFilters, s); + }, + [markDirty], + ); + const setQ = useCallback( + (v: string) => { + markDirty(); + setQState(v); + setAccountRaw(LS.plexQ, v); + }, + [markDirty], + ); const state = useMemo( () => ({ scope, show, sort, filters, q, playlistOpen }), diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 16e4bdcc..3afddc4b 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,4 +1,5 @@ import { useCallback, useState, useSyncExternalStore } from "react"; +import { useAccountRehydrate } from "./useAccountRehydrate"; // 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 @@ -180,14 +181,17 @@ export function writeJSON(key: string, value: unknown): void { /** 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 - * can be used before an async-loaded option list is known, keeping hook order stable. These hooks - * run in components that only mount once signed in, so the account is known. */ + * can be used before an async-loaded option list is known, keeping hook order stable. Consumers + * usually mount only once signed in (account known), but re-read on the account-pin transition too so + * one that mounts before the pin (a fresh tab's first paint) still restores. */ export function useAccountPersistedState( base: string, fallback = "", ): [string, (value: string) => void] { const [value, setValue] = useState(() => getAccountRaw(base) ?? fallback); + const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback)); const set = (next: string) => { + markDirty(); setValue(next); setAccountRaw(base, next); }; @@ -202,15 +206,17 @@ export function useAccountPersistedObject( defaults: T, ): [T, (patch: Partial) => void] { const [value, setValue] = useState(() => readAccountMerged(base, defaults)); + const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults))); const patch = useCallback( (p: Partial) => { + markDirty(); setValue((prev) => { const next = { ...prev, ...p }; writeAccount(base, next); return next; }); }, - [base], + [base, markDirty], ); return [value, patch]; }