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:
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { isPage, readPage, type Page } from "../lib/urlState";
|
import { isPage, readPage, type Page } from "../lib/urlState";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { pageTitleKey } from "../lib/pageMeta";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||||
import { setNavigator } from "../lib/nav";
|
import { setNavigator } from "../lib/nav";
|
||||||
|
|
||||||
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
// 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);
|
const channelViewRef = useRef(channelView);
|
||||||
channelViewRef.current = channelView;
|
channelViewRef.current = channelView;
|
||||||
|
|
||||||
const setPage = useCallback((next: Page) => {
|
// The landing page is per-account (PAGE_KEY), but this provider mounts above <App>, which pins the
|
||||||
// A channel page overlays the content column, so a nav click must close it even when the
|
// tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and
|
||||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
// falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry
|
||||||
if (next === pageRef.current && !channelViewRef.current) return;
|
// (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage)
|
||||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
// skips this if the user already navigated in the pre-pin window.
|
||||||
setPageState(next);
|
const markDirty = useAccountRehydrate(() => {
|
||||||
setAccountRaw(PAGE_KEY, next);
|
const stored = loadInitialPage();
|
||||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
if (stored === pageRef.current) return;
|
||||||
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
|
setPageState(stored);
|
||||||
// _yt/_chan markers) so each page starts at its root.
|
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
|
||||||
window.history.pushState({ sfPage: next }, "");
|
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) => {
|
const openChannel = useCallback((id: string, name?: string) => {
|
||||||
setChannelView({ id, name });
|
setChannelView({ id, name });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
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
|
// 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
|
// — 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);
|
return useContext(ActionsContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSelectedId(): number | null {
|
||||||
|
const s = getAccountRaw(LS.playlist);
|
||||||
|
return s ? Number(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
||||||
const [search, setSearchState] = useState("");
|
const [search, setSearchState] = useState("");
|
||||||
const [selectedId, setSelectedIdState] = useState<number | null>(() => {
|
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
|
||||||
const s = getAccountRaw(LS.playlist);
|
|
||||||
return s ? Number(s) : null;
|
// 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 setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||||
const setSelectedId = useCallback((id: number | null) => {
|
const setSelectedId = useCallback(
|
||||||
setSelectedIdState(id);
|
(id: number | null) => {
|
||||||
if (id != null) setAccountRaw(LS.playlist, String(id));
|
markDirty();
|
||||||
}, []);
|
setSelectedIdState(id);
|
||||||
|
if (id != null) setAccountRaw(LS.playlist, String(id));
|
||||||
|
},
|
||||||
|
[markDirty],
|
||||||
|
);
|
||||||
|
|
||||||
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
|
||||||
const actions = useMemo<PlaylistsActions>(
|
const actions = useMemo<PlaylistsActions>(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
|||||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||||
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
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 /
|
// 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
|
// 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,
|
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
// 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
|
// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds
|
||||||
// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral
|
// 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
|
// 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
|
// 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.
|
// into (and is never greeted by a stale value from) the feed.
|
||||||
@@ -58,45 +61,51 @@ export function usePlexActions(): PlexActions {
|
|||||||
return useContext(ActionsContext);
|
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 }) {
|
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||||
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
// 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".)
|
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||||
const [scope, setScopeState] = useState<string>(() => {
|
const [scope, setScopeState] = useState<string>(readPlexScope);
|
||||||
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
const [show, setShowState] = useState<string>(readPlexShow);
|
||||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
||||||
});
|
|
||||||
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 };
|
|
||||||
});
|
|
||||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
// 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>(() => {
|
const filters = useMemo<PlexFilters>(() => {
|
||||||
try {
|
try {
|
||||||
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
|
// 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]);
|
}, [filtersRaw]);
|
||||||
// Survives F5 (persisted), unlike the feed search — see the scope note above.
|
// 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 [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
|
||||||
|
|
||||||
const setScope = useCallback((v: string) => {
|
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
|
||||||
setScopeState(v);
|
const markDirty = useAccountRehydrate(() => {
|
||||||
setAccountRaw(LS.plexLibrary, v);
|
setScopeState(readPlexScope());
|
||||||
}, []);
|
setShowState(readPlexShow());
|
||||||
const setShow = useCallback((v: string) => {
|
setSortState(readPlexSort());
|
||||||
setShowState(v);
|
setFiltersRaw(readPlexFiltersRaw());
|
||||||
setAccountRaw(LS.plexShow, v);
|
setQState(readPlexQ());
|
||||||
}, []);
|
});
|
||||||
const setSort = useCallback((v: SortState) => {
|
|
||||||
setSortState(v);
|
const setScope = useCallback(
|
||||||
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
(v: string) => {
|
||||||
}, []);
|
markDirty();
|
||||||
const setFilters = useCallback((f: PlexFilters) => {
|
setScopeState(v);
|
||||||
const s = JSON.stringify(f);
|
setAccountRaw(LS.plexLibrary, v);
|
||||||
setFiltersRaw(s);
|
},
|
||||||
setAccountRaw(LS.plexFilters, s);
|
[markDirty],
|
||||||
}, []);
|
);
|
||||||
const setQ = useCallback((v: string) => {
|
const setShow = useCallback(
|
||||||
setQState(v);
|
(v: string) => {
|
||||||
setAccountRaw(LS.plexQ, v);
|
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>(
|
const state = useMemo<PlexState>(
|
||||||
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useState, useSyncExternalStore } from "react";
|
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
|
// 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
|
// 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
|
/** 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
|
* 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
|
* 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
|
* can be used before an async-loaded option list is known, keeping hook order stable. Consumers
|
||||||
* run in components that only mount once signed in, so the account is known. */
|
* 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(
|
export function useAccountPersistedState(
|
||||||
base: string,
|
base: string,
|
||||||
fallback = "",
|
fallback = "",
|
||||||
): [string, (value: string) => void] {
|
): [string, (value: string) => void] {
|
||||||
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
||||||
|
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
|
||||||
const set = (next: string) => {
|
const set = (next: string) => {
|
||||||
|
markDirty();
|
||||||
setValue(next);
|
setValue(next);
|
||||||
setAccountRaw(base, next);
|
setAccountRaw(base, next);
|
||||||
};
|
};
|
||||||
@@ -202,15 +206,17 @@ export function useAccountPersistedObject<T extends object>(
|
|||||||
defaults: T,
|
defaults: T,
|
||||||
): [T, (patch: Partial<T>) => void] {
|
): [T, (patch: Partial<T>) => void] {
|
||||||
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
||||||
|
const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
|
||||||
const patch = useCallback(
|
const patch = useCallback(
|
||||||
(p: Partial<T>) => {
|
(p: Partial<T>) => {
|
||||||
|
markDirty();
|
||||||
setValue((prev) => {
|
setValue((prev) => {
|
||||||
const next = { ...prev, ...p };
|
const next = { ...prev, ...p };
|
||||||
writeAccount(base, next);
|
writeAccount(base, next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[base],
|
[base, markDirty],
|
||||||
);
|
);
|
||||||
return [value, patch];
|
return [value, patch];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user