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
+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];
}