Files
siftlode/frontend/src/components/NavigationProvider.tsx
T
peter ea6d829c29 refactor(frontend): the context providers adjust state in render, not after it (E27 S2)
Eleven suppressions across the five providers. The recurring shape here was "an effect that
undoes something when you navigate away", and every one of them ran a commit LATE — the new
page painted once while the old module's state was still live.

Adjusted during render instead (React discards that pass rather than committing it, so there
is no in-between frame):
- SavedViewsProvider reset the sticky view and closed its modal from an effect on `source`, so
  the first Plex render carried the feed's sticky id — long enough for a stale view chip. It
  also followed the active view into stickyId from a second effect; that one converges by
  construction, since preferSticky returns the sticky match when there is one.
- NavigationProvider ended a live YouTube search from an effect on `page`. Keyed on the page
  CHANGE rather than derived from it, because "ended" has to be permanent — deriving would have
  quietly brought the search back on return to the feed.
- ChannelsProvider's manager focus is now simply DERIVED from the page: it means nothing off
  the Channels page, and focusChannel sets it and navigates in one handler.

Honest deps by freezing what is genuinely mount-time:
- FeedFiltersProvider persisted a share link's filters with an empty dep list; listing `filters`
  honestly would have re-persisted on every edit and spun. The mount value is frozen into its
  own state, so all three deps are stable and the effect says what it does.
- ChannelsProvider's URL-seed layout effect and NavigationProvider's initial history stamp get
  the same treatment. The three slice setters they call were already stable useCallbacks
  (useFilterState / useSortState / useAccountPersistedState all return one).
- SavedViewsProvider reads `cfg` through a ref: it re-memoizes on every filter edit, and the
  per-source seed guard already enforces once. That also made the set-state finding go away —
  with the render value gone, the checker can see the seed for what it is.

WizardProvider keeps its one escape hatch, now inline with the invariant: auto-opening is a
one-way decision taken from server data as it arrives, and deriving it would reopen a wizard
the user had just closed.

Suppressions 89 -> 78; exhaustive-deps 27 -> 23.
2026-09-21 00:12:44 +02:00

237 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useMe } from "../lib/useMe";
import { urlWithParams, type Page } from "../lib/urlState";
import { LOGS_KEYS } from "../lib/logsUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
import {
closeOverlaysThenNavigate,
nextNavId,
notifyNav,
rememberNavPosition,
} from "../lib/history";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
import {
NavigationActionsContext,
NavigationStateContext,
type NavigationActions,
type NavigationState,
} from "./navigationContext";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
// consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree.
// Mirrors ConfirmProvider (context + default value + one-line hooks); composed in main.tsx inside
// ConfirmProvider, around <App/>.
//
// The contexts, their value types and the hooks live in navigationContext.ts.
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
const [page, setPageState] = useState<Page>(loadInitialPage);
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
// Back steps player → search → feed instead of the search vanishing without a history entry.
const [ytSearch, setYtSearch] = useState<string | null>(() => {
const st = window.history.state;
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
// detail loads) AND is mirrored to the address bar as ?chan=<id>, so Back closes it, a reload restores
// it, and a bookmarked ?chan link opens it (unlike ytSearch, which is dropped). Reload of an in-app
// entry reads history.state._chan; a fresh bookmark has only the ?chan param.
const [channelView, setChannelView] = useState<ChannelView | null>(() =>
channelViewFrom(window.history.state, window.location.search),
);
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
// would recreate them on every navigation and re-render every actions consumer (e.g. VideoCard).
// Written during render (the sanctioned "latest value" pattern): the ref is only READ in event
// handlers after commit, and this provider never suspends or renders in a transition, so no
// discarded render can leave it stale.
const pageRef = useRef(page);
pageRef.current = page;
const channelViewRef = useRef(channelView);
channelViewRef.current = channelView;
const setPage = useCallback(
(next: Page, then?: () => void) =>
// Never under an open overlay — see the interface doc. The refs are read when the push
// actually happens, which over an open player is a moment after this call.
closeOverlaysThenNavigate(() => {
// 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) {
then?.();
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. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
// root, and the address bar reflects the page (feed is implicit) while clearing the
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
// their page. The item it was opened from is still focused: the state setters above have not
// re-rendered anything yet.
rememberNavPosition();
window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams({
page: next === "feed" ? null : next,
chan: null,
v: null,
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(LOGS_KEYS.map((k) => [k, null])),
}),
);
notifyNav();
then?.();
}),
[],
);
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
// under an open overlay: the player's channel link and the palette's channel rows call it from
// inside one.
const openChannel = useCallback((id: string, name?: string) => {
closeOverlaysThenNavigate(() => {
const url = urlWithParams({ chan: id });
if (window.history.state?._chan) {
const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
} else {
const st = rememberNavPosition();
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
}
setChannelView({ id, name });
});
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
else {
// No owned history entry (e.g. opened straight from a ?chan bookmark): just clear the state and
// drop the ?chan param from the address bar.
setChannelView(null);
window.history.replaceState(window.history.state, "", urlWithParams({ chan: null }));
}
}, []);
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt)
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else {
// New sub-view entry.
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
}
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
}, []);
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
useEffect(() => setNavigator(setPage), [setPage]);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed. Adjusted
// DURING the render that changes the page, against the page we were last on: as an effect it ran
// after the new page had already committed, so for one frame a module rendered with a live search
// still in the nav state. Keyed on the CHANGE rather than derived from `page`, because "ended" has
// to be permanent — a derived null would quietly come back when you returned to the feed.
const [pageSearchWasOn, setPageSearchWasOn] = useState(page);
if (pageSearchWasOn !== page) {
setPageSearchWasOn(page);
if (page !== "feed") setYtSearch(null);
}
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
// shows the channel name. Signed out, the page on screen is the public landing whatever `page`
// says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19).
const signedIn = useMe() != null;
useEffect(() => {
const brand = "Siftlode";
const label = !signedIn
? null
: channelView
? channelView.name || t("header.channelManager")
: page === "feed"
? null
: t(pageTitleKey(page));
document.title = label ? `${label} · ${brand}` : brand;
}, [signedIn, page, channelView, i18n.language, t]);
// The page this tab LOADED on. Frozen, because the stamp below belongs to the initial history
// entry: every later page change writes its own entry through setPage.
const [mountPage] = useState(page);
// In-app Back/Forward: stamp the initial entry with the current page (mirrored to the address bar),
// then sync state from history.state on popstate — the browser restores each entry's URL for us, so
// the popstate handler only needs to read history.state.
useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
// are always kept so a channel page survives F5.
const st = window.history.state || {};
// Mirror the resolved initial page to the address bar (feed implicit) so a page reached from the
// stored landing pref — not just from the URL — is itself bookmarkable. urlWithParams preserves any
// ?chan / ?v / ?pl / filter params the loaders already read.
const url = urlWithParams({ page: mountPage === "feed" ? null : mountPage });
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: mountPage }, "", url);
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: mountPage }, "", url);
}
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
setPageState(p);
setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
// restores or clears them to match the entry we landed on.
setYtSearch((e.state?._yt as string) ?? null);
// The channel page rides in history.state._chan, with a ?chan URL fallback for a fresh bookmark
// entry (URL-only, no _chan) — reading e.state alone would wrongly close the channel when a
// popstate lands here (e.g. after an overlay's programmatic history.back()). channelViewFrom
// shares this resolution with the seed initializer above.
setChannelView(channelViewFrom(e.state, window.location.search));
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, [mountPage]);
const state = useMemo<NavigationState>(
() => ({ page, channelView, ytSearch }),
[page, channelView, ytSearch],
);
const actions = useMemo<NavigationActions>(
() => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
);
return (
<NavigationActionsContext.Provider value={actions}>
<NavigationStateContext.Provider value={state}>{children}</NavigationStateContext.Provider>
</NavigationActionsContext.Provider>
);
}