E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
231 lines
11 KiB
TypeScript
231 lines
11 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { useLatest } from "../lib/useLatest";
|
|
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 { useDocumentObjectTitle } from "../lib/documentTitle";
|
|
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),
|
|
);
|
|
|
|
// Latest-value 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). Only READ in event handlers, after commit.
|
|
const pageRef = useLatest(page);
|
|
const channelViewRef = useLatest(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?.();
|
|
}),
|
|
[pageRef, channelViewRef],
|
|
);
|
|
|
|
// 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);
|
|
}
|
|
|
|
// The browser tab names the place and the object in it (DESIGN § Composition, "Where am I"):
|
|
// "<object> · <module> · Siftlode". The object is the open view's own heading (a channel, a Plex
|
|
// title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same
|
|
// as one opened in the app. 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;
|
|
const objectTitle = useDocumentObjectTitle();
|
|
useEffect(() => {
|
|
const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : [];
|
|
document.title = [...parts.filter(Boolean), "Siftlode"].join(" · ");
|
|
}, [signedIn, page, objectTitle, 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>
|
|
);
|
|
}
|