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 });