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.
237 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|