fix(nav,plex,playlists): extend account-pin re-hydrate to every affected provider

The fresh-tab restore bug wasn't Channels-specific: any provider that mounts above
<App> and reads per-account state at useState-init hit it, since <App> 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.
This commit is contained in:
2026-08-14 02:56:55 +02:00
parent b57e4fb9bc
commit 9917d908eb
4 changed files with 155 additions and 82 deletions
+30 -12
View File
@@ -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 <App>, 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 });
+19 -8
View File
@@ -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<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
// Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab):
// this provider mounts above <App>, 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<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
+97 -59
View File
@@ -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 <App/>).
//
// 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
// <App>, 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<string>(() => {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
});
const [show, setShowState] = useState<string>(() => 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<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 };
});
const [scope, setScopeState] = useState<string>(readPlexScope);
const [show, setShowState] = useState<string>(readPlexShow);
const [sort, setSortState] = useState<SortState>(readPlexSort);
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() => getAccountRaw(LS.plexFilters) ?? "{}");
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
const filters = useMemo<PlexFilters>(() => {
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<string>(() => getAccountRaw(LS.plexQ) ?? "");
const [q, setQState] = useState<string>(readPlexQ);
const [playlistOpen, setPlaylistOpen] = useState<number | null>(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<PlexState>(
() => ({ scope, show, sort, filters, q, playlistOpen }),
+9 -3
View File
@@ -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<string>(() => 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<T extends object>(
defaults: T,
): [T, (patch: Partial<T>) => void] {
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
const patch = useCallback(
(p: Partial<T>) => {
markDirty();
setValue((prev) => {
const next = { ...prev, ...p };
writeAccount(base, next);
return next;
});
},
[base],
[base, markDirty],
);
return [value, patch];
}