fix(channels): re-hydrate per-account manager state once the account is pinned

The provider tree mounts above <App>, which pins this tab's account only after /api/me
resolves — so on a genuinely fresh tab (empty sessionStorage) the manager's sort, filters,
view and layout initialised from an unknown account and stayed at defaults until a second
reload. Make the active account reactive (a subscribable store + useActiveAccountId via
useSyncExternalStore; setActiveAccount/clearActiveAccount notify) and re-read the stored
value when the account transitions null→id, via a shared useAccountRehydrate hook. A
markDirty guard (called by each owning setter) prevents clobbering a value the user changed
in the brief pre-pin window; the effect is keyed on the account id only, so the user's own
writes can't loop it. useSortState, useFilterState and ChannelsProvider (view/layout) all
adopt it. Affects sort identically — this was a long-standing caveat, not new to R13.
This commit is contained in:
2026-08-14 02:37:28 +02:00
parent ca9dd54923
commit b57e4fb9bc
9 changed files with 160 additions and 27 deletions
+37 -19
View File
@@ -13,6 +13,7 @@ import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
import { type SortState } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import type { FilterMap } from "./tableHeader";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelsView } from "./Channels";
@@ -129,6 +130,15 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
const [focusName, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
// view + layout (like sort/filters) initialise from an unknown account on first load, because this
// provider mounts above <App>, which pins the account only after /api/me resolves. Re-read them once
// the account is known. Uses the raw state setters (not setView/setLayout) so it's a silent
// correction, not a history-pushing navigation; markManagerDirty guards a user switch made pre-pin.
const markManagerDirty = useAccountRehydrate(() => {
setViewState(clampView(getAccountRaw(LS.channelsView)));
setLayoutState(parseChannelLayout(getAccountRaw(LS.channelLayout)));
});
// Latest-value ref so the STABLE setView can compare against the current tab without depending on
// it (a dep would recreate the action on every switch and re-render every actions consumer).
// Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`.
@@ -139,25 +149,33 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// The active tab rides in history.state (`_chTab`), the same way the channel page (`_chan`) and the
// live YouTube search (`_yt`) do — so Back steps back through the tabs you actually visited instead
// of jumping straight out to the previous module.
const setView = useCallback((v: ChannelsView) => {
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
if (v === viewRef.current) return;
setViewState(v);
setAccountRaw(LS.channelsView, v);
// Only own an entry while the manager is already on screen. Arriving from elsewhere
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
// one navigation never costs two Back presses.
if (window.history.state?.sfPage === "channels") {
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
// Back re-open the channel overlay or a spent search on top of the tab.
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
}
}, []);
const setLayout = useCallback((l: ChannelLayout) => {
setLayoutState(l);
setAccountRaw(LS.channelLayout, l);
}, []);
const setView = useCallback(
(v: ChannelsView) => {
// Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
if (v === viewRef.current) return;
markManagerDirty();
setViewState(v);
setAccountRaw(LS.channelsView, v);
// Only own an entry while the manager is already on screen. Arriving from elsewhere
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
// one navigation never costs two Back presses.
if (window.history.state?.sfPage === "channels") {
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
// Back re-open the channel overlay or a spent search on top of the tab.
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
}
},
[markManagerDirty],
);
const setLayout = useCallback(
(l: ChannelLayout) => {
markManagerDirty();
setLayoutState(l);
setAccountRaw(LS.channelLayout, l);
},
[markManagerDirty],
);
const focusChannel = useCallback(
(name: string) => {
setFocusName(name);