fix(boot): a new tab knows its account before anything renders

With Settings -> Appearance -> Startup turned off, every NEW tab or window still
opened on Home; only a same-tab reload honoured it. The provider tree mounted
before /api/me answered, so NavigationProvider's initializer could not read the
per-account opt-out, landed on Home and mirrored ?page=home into the address bar.
When App pinned the account a moment later, the re-hydrate re-read that mirror
and stayed on Home. The same mount-then-patch shape made a new tab start on
default filters, the default theme and the browser language, and correct each
after the first paint.

BootGate (main.tsx, above the providers) now owns the setup-status and /api/me
queries and mounts the tree only once the identity is settled: it pins the tab's
account and adopts the account's language in a layout effect, so the first frame
of the app is already right. The tree is keyed on the account id, which makes
"per-account state is read once, for this account" a structural guarantee.

With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the
reactive active-account store, the markDirty guards, the pin-deferred saved-view
seed and the present-dimension bookkeeping of the shared Channels/Plex links.
PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the
me cache instead of adopting it in an effect; the theme is applied in layout
effects. The app chunk is fetched in parallel with the probe, and the boot screen
only fades its label in after 400 ms, on one clock across the gate and the
Suspense fallback.

Module switches no longer flash a placeholder either. Measured frame by frame
in Chromium and WebKit, every first visit to a module showed "Loading..." for
17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's
LoadingState. Pages are now lazyWithPreload components, and App fetches the
chunks of every module the account can reach (plus the channel page) once the
first page is idle; a preloaded page renders synchronously and never suspends.
LoadingState, the one shared loading visual, fades in only after 300 ms, so a
fast load shows nothing in between and a slow one still gets its spinner.
This commit is contained in:
2026-09-14 22:29:33 +02:00
parent 6169e32ca5
commit 5346efcd10
34 changed files with 636 additions and 731 deletions
+46 -78
View File
@@ -2,9 +2,9 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
import { notifyActionError } from "./lib/actionErrors";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import { setLanguage, type LangCode } from "./i18n";
import { clearParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
import {
@@ -24,7 +24,9 @@ import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersP
import { useChannelLayout } from "./components/ChannelsProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
import { moduleDef, moduleOrder } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
import { lazyWithPreload } from "./lib/lazyWithPreload";
import { LoadingState } from "./components/ui/QueryState";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
@@ -38,7 +40,6 @@ import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap";
import { StateMessage } from "./components/ui/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
@@ -52,7 +53,7 @@ import { FRONTEND_VERSION } from "./lib/version";
// The page modules (and their side rails) are code-split per route via the module registry
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
// two modals, which sit outside the registry-driven content column.
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const ChannelPage = lazyWithPreload(() => import("./components/ChannelPage"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
@@ -79,13 +80,20 @@ export default function App() {
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
const view = useFeedView();
const { setView } = useFeedViewActions();
const queryClient = useQueryClient();
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
feed: loadLayout("feed"),
plex: loadLayout("plex"),
playlists: loadLayout("playlists"),
}));
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
// changed on another device is right on the first paint; the local copy covers a key the server
// has never stored.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => {
const prefs = queryClient.getQueryData<Me | null>(qk.me())?.preferences;
const seed = (p: PanelId) => {
const raw = prefs?.[PREF_KEY[p]];
return raw ? normalizeLayout(p, raw) : loadLayout(p);
};
return { feed: seed("feed"), plex: seed("plex"), playlists: seed("playlists") };
});
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
@@ -119,26 +127,15 @@ export default function App() {
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
const queryClient = useQueryClient();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
// We check this before anything else and render the wizard; `me` is held back until we know the
// instance is set up (otherwise it would 503 against the setup-mode lock).
// Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls
// and waits for: App mounts only once both have answered, so their caches are always populated here.
const setupQuery = useQuery({
queryKey: qk.setupStatus(),
queryFn: api.setupStatus,
staleTime: Infinity,
enabled: false,
});
const needsSetup = setupQuery.data?.configured === false;
// Poll the session so an account suspended/deleted by an admin is noticed even with no
// interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
// Only poll while signed in (data present) — polling on the public login page is pointless and
// its periodic refetch caused a visible flicker there.
const meQuery = useQuery({
queryKey: qk.me(),
queryFn: api.me,
enabled: setupQuery.isSuccess && !needsSetup,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
@@ -155,14 +152,25 @@ export default function App() {
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
);
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
// an account (it was riding the session's default) would silently swap identity when ANOTHER
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
// tab always sends its own account header, so cross-tab changes can't reach it. An explicit
// switch on THIS tab sets the override itself (so this no-ops then).
// Fetch the code of every module this account can open — and the channel page — once the first page
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
// requestIdleCallback, hence the timer).
useEffect(() => {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [meQuery.data?.id]);
const me = meQuery.data;
if (!me) return;
const run = () => {
preloadPages([...moduleOrder(me), "settings"]);
ChannelPage.preload().catch(() => {});
};
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
if (idle) {
const id = idle(run, { timeout: 3000 });
return () => window.cancelIdleCallback(id);
}
const id = window.setTimeout(run, 1000);
return () => window.clearTimeout(id);
}, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
@@ -238,28 +246,6 @@ export default function App() {
clearParams("admin");
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse
// flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the
// Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by
// FeedViewProvider — each via its own passive qk.me() observer.
useEffect(() => {
const prefs = meQuery.data?.preferences;
if (!prefs) return;
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefs[PREF_KEY[p]];
if (raw) {
const l = normalizeLayout(p, raw);
setPanelLayouts((prev) => ({ ...prev, [p]: l }));
saveLayoutLocal(p, l);
}
});
const lang = prefs.language;
if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
// (Per-account filters — incl. the demo account's whole-library default — are loaded by the
// dedicated effect above, which also covers accounts that have no stored preferences.)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]);
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
// fresh load (which is why the notice used to linger until F5 after an approval).
@@ -304,32 +290,14 @@ export default function App() {
}
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (setupQuery.isLoading)
return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
);
if (needsSetup) return <SetupWizard />;
if (meQuery.isLoading)
return (
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
);
// /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
// network/5xx failure, and no data means "not signed in" → the public landing.
if (meQuery.error)
return (
<div className="min-h-screen grid place-items-center">
<StateMessage tone="error" onRetry={() => meQuery.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
</div>
);
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
// probe — so no data here means "not signed in" → the public landing.
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
const pageFallback = (
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
);
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
const pageFallback = <LoadingState className="flex-1 justify-center" />;
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
@@ -0,0 +1,93 @@
// @vitest-environment jsdom
//
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no
// pinned account in sessionStorage) built the app for "no account" first — so with Settings →
// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and
// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the
// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right.
//
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import BootGate from "./BootGate";
import { NavigationProvider, useNavigation } from "./NavigationProvider";
import { api, type Me } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import i18n from "../i18n";
const renders: { page: string; lang: string }[] = [];
function Probe() {
const { page } = useNavigation();
const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language });
return <div data-testid="app">{page}</div>;
}
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<BootGate>
<NavigationProvider>
<Probe />
</NavigationProvider>
</BootGate>
</QueryClientProvider>,
);
}
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
beforeEach(async () => {
localStorage.clear();
sessionStorage.clear(); // a NEW tab: nothing pinned yet
window.history.replaceState(null, "", "/");
renders.length = 0;
await i18n.changeLanguage("en");
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("BootGate", () => {
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "downloads");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
});
it("renders the app in the account's language from its first render", async () => {
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
mount();
await screen.findByTestId("app");
expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu"));
});
it("mounts nothing of the app while the identity is still unknown", async () => {
vi.spyOn(api, "me").mockReturnValue(new Promise<Me | null>(() => {}));
mount();
await act(async () => {}); // the setup probe resolves; `me` never does
expect(screen.queryByTestId("app")).toBeNull();
expect(renders).toHaveLength(0);
});
it("signed out: mounts the app (App shows the landing) without pinning an account", async () => {
vi.spyOn(api, "me").mockResolvedValue(null);
mount();
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
});
});
+110
View File
@@ -0,0 +1,110 @@
import { Fragment, useLayoutEffect, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
import { qk } from "../lib/queryKeys";
import i18n, { isSupported, setLanguage } from "../i18n";
import { StateMessage } from "./ui/QueryState";
// The boot sequence, in ONE place: nothing of the app renders until this tab knows WHO it is.
//
// Per-account state is everywhere — the landing page, the feed's filters, the Plex scope, the manager's
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
// patched each piece once <App> pinned the account. Every patch was a visible jump at best, and at
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
// the provider tree — whose initializers are then simply right, with nothing to re-read.
//
// The tree is keyed on the account id, which enforces that in code rather than in a comment: if a tab's
// identity ever changed without a reload, every per-account initializer would run again for the new
// account instead of holding the previous one's values. (Today every switch reloads — NavSidebar.)
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way. On a normal
* connection that is a few hundred milliseconds, and a "Loading…" label flashed for that long reads as
* a flicker, not as information — so the label only fades in once the wait is long enough to need one.
* The delay counts from navigation start, not from mount, so the gate's screen and the Suspense
* fallback that may follow it keep one clock instead of each restarting the fade. */
export function BootScreen() {
const { t } = useTranslation();
const [wait] = useState(() => LABEL_DELAY_MS - performance.now());
return (
<div className="min-h-screen grid place-items-center text-muted">
<span style={wait > 0 ? { animation: `fadeIn 0.2s ease ${wait}ms both` } : undefined}>
{t("common.loading")}
</span>
</div>
);
}
const LABEL_DELAY_MS = 400;
export default function BootGate({ children }: { children: ReactNode }) {
const { t } = useTranslation();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode, where
// every other route is locked — so `me` is held back until the instance is known to be set up
// (otherwise it would 503 against the setup-mode lock).
const setupQuery = useQuery({
queryKey: qk.setupStatus(),
queryFn: api.setupStatus,
staleTime: Infinity,
});
const needsSetup = setupQuery.data?.configured === false;
// THE fetching `me` query; every other reader is a passive (enabled:false) observer of its cache.
// Polled while signed in so an account an admin suspends or deletes is noticed with no interaction:
// the refetch 401s and App's handler drops to the login page. Not polled signed out — that refetch
// is pointless and used to flicker the public landing.
const meQuery = useQuery({
queryKey: qk.me(),
queryFn: api.me,
enabled: setupQuery.isSuccess && !needsSetup,
refetchInterval: (query) => (query.state.data ? 60_000 : false),
});
const me = meQuery.data;
// Who this tab is, once known: an account id, `null` for "nobody" (signed out, setup mode, or no
// setup answer at all — App renders the landing / the wizard for those), `undefined` while unknown.
// Keyed on the DATA, not the status: a background refetch that fails keeps the last answer, so a
// server blip never tears the whole app down to this screen.
const identity: number | null | undefined = setupQuery.isPending
? undefined
: !setupQuery.isSuccess || needsSetup
? null
: me === undefined
? undefined
: (me?.id ?? null);
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
// before the browser paints: the frame that first shows the app already has the account pinned.
// • Pin this tab to the account it loaded as. Without the pin, a tab riding the session's default
// account would silently swap identity when ANOTHER tab changes that default (adds or switches
// an account); once pinned it sends its own account header. An explicit switch on THIS tab sets
// the pin itself, so this is a no-op then.
// • Adopt the account's language, which follows the account across devices — the local copy only
// knows what this browser last showed.
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
useLayoutEffect(() => {
if (identity === undefined) return;
if (me && getActiveAccount() == null) setActiveAccount(me.id);
const lang = me?.preferences?.language;
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
setReadyFor(identity);
// `me` is read for the identity it produced; a background refetch of the same account must not
// re-run the adoption over a language the user has just changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [identity]);
// No answer at all (a network or 5xx failure on the very first probe): say so, with a retry. After
// a retry the identity lands and the tree mounts from scratch, like any first load.
if (me === undefined && meQuery.isError)
return (
<div className="min-h-screen grid place-items-center">
<StateMessage tone="error" onRetry={() => meQuery.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
</div>
);
if (identity === undefined || readyFor !== identity) return <BootScreen />;
return <Fragment key={identity ?? "anon"}>{children}</Fragment>;
}
@@ -17,7 +17,6 @@ vi.mock("./NavigationProvider", () => ({
}));
import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
import { setActiveAccount } from "../lib/api/core";
const wrapper = ({ children }: { children: ReactNode }) => (
<ChannelsProvider>{children}</ChannelsProvider>
@@ -101,20 +100,17 @@ describe("ChannelsProvider — S8 shareable URL", () => {
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
});
it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => {
// Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED
// dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
// stored sort + filter map. The fix persists ONLY the URL-present dimensions.
it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => {
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
// persisting the seed overwrote the account's real stored sort + filter map.
localStorage.setItem(
"siftlode.channelSort.1",
JSON.stringify({ key: "subscribers", dir: "desc" }),
);
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
const { result } = render();
act(() => setActiveAccount(1)); // App pins the account after /api/me
// The present dim (tab) is adopted from the link...
expect(result.current.view).toBe("hidden");
+15 -49
View File
@@ -9,12 +9,11 @@ import {
useState,
type ReactNode,
} from "react";
import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
import { getAccountRaw, LS } from "../lib/storage";
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
import { hydrateSort, type SortState } from "../lib/columnSort";
import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState";
import { useActiveAccountId } from "../lib/activeAccount";
import { useUrlMirror } from "../lib/useUrlMirror";
import { rememberNavPosition } from "../lib/history";
import {
@@ -74,25 +73,16 @@ function readStoredFilters(): FilterMap {
// present, the URL wins over this account's stored view — read once at mount and applied over the slice
// hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared
// manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others).
// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally —
// otherwise a partial shared link silently resets the omitted stored sort / filter map.
type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean };
type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent };
function readChannelsUrlSeed(): ChannelsUrlSeed | null {
// The base is this account's stored view (BootGate pins the account before this provider mounts), so
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
function readChannelsUrlSeed(): ChannelsUrlState | null {
const params = new URLSearchParams(window.location.search);
if (!hasChannelsParams(params)) return null;
const state = channelsParamsToState(params, {
return channelsParamsToState(params, {
view: clampView(getAccountRaw(LS.channelsView)),
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
filters: readStoredFilters(),
});
return {
state,
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
};
}
type ChannelsState = {
@@ -168,10 +158,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
// they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above
// <App>, which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout
// heal them for display. setView layers the history logic (below) on top of the raw setter.
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook).
// Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history
// logic (below) on top of the raw setter.
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
const view = clampView(viewRaw);
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table");
@@ -185,43 +174,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
const [urlSeed] = useState(readChannelsUrlSeed);
const accountId = useActiveAccountId();
const seededRef = useRef(false);
const persistedRef = useRef(false);
// Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
// (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
// (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
// over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history
// entry. A normal load has urlSeed === null and this is a no-op.
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
useLayoutEffect(() => {
if (!urlSeed || seededRef.current) return;
seededRef.current = true;
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
const { state, present } = urlSeed;
if (present.view && state.view !== view) setViewRaw(state.view);
if (present.filters) setFilters(state.filters);
if (present.sort) setSort(state.sort);
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
setFilters(urlSeed.filters);
setSort(urlSeed.sort);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account
// yet). Persist the seed to the account once it's known, so the shared view also survives a later
// PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate
// already skips (marked dirty above), so this only touches storage, not state.
useEffect(() => {
if (!urlSeed || accountId == null || persistedRef.current) return;
persistedRef.current = true;
// Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults
// (no account yet), so writing them here would overwrite the account's real stored sort/filter map.
const { state, present } = urlSeed;
if (present.view) setAccountRaw(LS.channelsView, state.view);
if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters));
if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort));
}, [urlSeed, accountId]);
// 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`.
@@ -237,7 +203,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
// 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;
setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab)
setViewRaw(v); // persists (via usePersistedTab)
// 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.
+18 -30
View File
@@ -8,14 +8,14 @@ import {
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import {
api,
getActiveAccount,
coerceTagModes,
coerceUntagged,
DEFAULT_TAG_MODES,
type FeedFilters,
type Me,
} from "../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
@@ -53,9 +53,8 @@ const DEFAULT_FILTERS: FeedFilters = {
const FILTERS_KEY = LS.filters;
// This account's own persisted filters (per-account keys — never another account's). When the
// account isn't known yet (first load, before the tab is pinned), start from defaults; the post-login
// effect loads the real ones once the id arrives.
// This account's own persisted filters (per-account keys — never another account's). No account
// (signed out) → defaults.
export function loadAccountFilters(id: number | null): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
@@ -87,10 +86,12 @@ export function loadAccountFilters(id: number | null): FeedFilters {
}
// URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(): FeedFilters {
function loadInitialFilters(me: Me | null | undefined): FeedFilters {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
return loadAccountFilters(getActiveAccount());
const f = loadAccountFilters(getActiveAccount());
// The shared demo account has no subscriptions, so default it to the whole library.
return me?.is_demo ? { ...f, scope: "all" } : f;
}
interface FeedFiltersActions {
@@ -108,18 +109,18 @@ export function useFeedFiltersActions(): FeedFiltersActions {
}
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const qc = useQueryClient();
// Seeded for the account this tab is — BootGate mounts this provider only once the account is
// pinned and `me` is cached, so this is the account's own view from the first render.
const [filters, setFiltersState] = useState<FeedFilters>(() =>
loadInitialFilters(qc.getQueryData<Me | null>(qk.me())),
);
// Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured
// once at mount — such filters win over the account's stored view and are persisted to the account.
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search)),
);
// Passive observer: App owns the fetching `qk.me()` query (gated on setup state); enabled:false
// here reads its cache and re-renders when it lands, without triggering a second fetch (which
// could 503 in setup mode). We only need id + is_demo, which don't change on background refetch.
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const setFilters = useCallback((next: FeedFilters) => {
setFiltersState(next);
// Persist under THIS tab's active account so filters don't bleed between accounts.
@@ -135,25 +136,12 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const { page } = useNavigation();
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
// Load THIS account's own filters when the account first resolves or changes (login / switch /
// add). Filters are per-account localStorage state, so another account's last view (incl. its
// default saved view) must never carry over. A share link's filters (captured at mount) win and
// are persisted to the account instead. Runs on id change only, so a 60s background refetch of
// the same account never clobbers the filters you're editing.
// A share link's filters (captured at mount) win over the account's stored view and are persisted
// to it once, so the view the link opened also survives a later plain reload.
useEffect(() => {
const id = meQuery.data?.id;
if (id == null) return;
if (initialHadUrlFilters) {
const key = accountKey(FILTERS_KEY, id);
if (key) writeJSON(key, filters);
return;
}
let f = loadAccountFilters(id);
// The shared demo account has no subscriptions, so default it to the whole library.
if (meQuery.data?.is_demo) f = { ...f, scope: "all" };
setFiltersState(f);
if (initialHadUrlFilters) setFilters(filters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]);
}, []);
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
+2 -2
View File
@@ -22,8 +22,8 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
});
const configured = keyQ.data?.configured ?? false;
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
// have no password to check. Reactive passive observer of the App-owned qk.me() query (enabled:
// false — App does the fetching), so hasPassword stays correct even if me resolves after mount.
// have no password to check. Passive observer of the qk.me() query BootGate fetches (enabled:false),
// so hasPassword follows the cache when a refetch changes it.
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const hasPassword = meQ.data?.has_password ?? false;
+4 -31
View File
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
import { urlWithParams, type Page } from "../lib/urlState";
import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav";
import {
closeOverlaysThenNavigate,
@@ -102,27 +101,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const channelViewRef = useRef(channelView);
channelViewRef.current = channelView;
// The landing page is per-account (PAGE_KEY), but this provider mounts above <App>, which pins the
// tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and
// falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry
// (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage)
// skips this if the user already navigated in the pre-pin window.
const markDirty = useAccountRehydrate(() => {
const stored = loadInitialPage();
if (stored === pageRef.current) return;
setPageState(stored);
setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative
// Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel
// (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page,
// rather than being orphaned from history so Back can't close it. Mirror the corrected page to the
// address bar too (urlWithParams keeps any ?chan/filter params already there).
window.history.replaceState(
{ ...(window.history.state || {}), sfPage: stored },
"",
urlWithParams({ page: stored === "feed" ? null : stored }),
);
});
const setPage = useCallback(
(next: Page) =>
// Never under an open overlay — see the interface doc. The refs are read when the push
@@ -131,7 +109,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// 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) return;
markDirty();
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
@@ -163,15 +140,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
);
notifyNav();
}),
[markDirty],
[],
);
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
//
// 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.
// 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 });
@@ -197,7 +171,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}, []);
const enterYtSearch = useCallback((q: string) => {
// Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt.
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.
+4 -13
View File
@@ -1,6 +1,5 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
import { readSelectedId } from "./playlistsInit";
@@ -43,19 +42,11 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
// Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab):
// this provider mounts above <App>, which pins the account only after /api/me.
const markDirty = useAccountRehydrate(() => setSelectedIdState(readSelectedId()));
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setSelectedId = useCallback(
(id: number | null) => {
markDirty();
setSelectedIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
},
[markDirty],
);
const setSelectedId = useCallback((id: number | null) => {
setSelectedIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
}, []);
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
// (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
+57 -111
View File
@@ -1,4 +1,12 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
@@ -10,7 +18,6 @@ import {
type PlexUrlState,
} from "../lib/plexUrlState";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
@@ -20,10 +27,8 @@ import { useNavigation } from "./NavigationProvider";
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds
// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above
// <App>, which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload
// (an account SWITCH does full-reload, but the FIRST load does not). `playlistOpen` is an ephemeral
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
@@ -95,7 +100,7 @@ export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
// The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate.
// The per-account init readers, shared by the useState seeds and the shared-link seed's base.
function readPlexScope(): string {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
@@ -144,45 +149,31 @@ function readPlexFilters(): PlexFilters {
return EMPTY_PLEX_FILTERS;
}
}
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the
// account's real stored value for the absent ones — otherwise a partial link silently resets them.
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent };
function readPlexUrlSeed(): PlexUrlSeed | null {
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
function readPlexUrlSeed(): PlexUrlState | null {
const params = new URLSearchParams(window.location.search);
if (!hasPlexParams(params)) return null;
const state = plexParamsToState(params, {
return plexParamsToState(params, {
scope: readPlexScope(),
sort: readPlexSort(),
show: readPlexShow(),
filters: readPlexFilters(),
});
return {
state,
present: {
scope: params.has("pscope"),
sort: params.has("psort"),
show: params.has("pshow"),
filters: params.has("pf"),
},
};
}
export function PlexProvider({ children }: { children: ReactNode }) {
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
// Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to
// the account (the effect further down), so the shared view also survives a later plain reload.
const [urlSeed] = useState(readPlexUrlSeed);
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [scope, setScopeState] = useState<string>(() => urlSeed?.state.scope ?? readPlexScope());
const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
const [scope, setScopeState] = useState<string>(() => urlSeed?.scope ?? readPlexScope());
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
);
const filters = useMemo<PlexFilters>(() => {
try {
@@ -203,87 +194,42 @@ export function PlexProvider({ children }: { children: ReactNode }) {
// PlexBrowse consumes it into an info/show history subview then clears it.
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
const markDirty = useAccountRehydrate(() => {
if (urlSeed) {
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
// default over the account's real stored value — re-read the stored value instead, exactly like a
// normal load. This is the fix for a partial shared link silently resetting the omitted slices.
const { state, present } = urlSeed;
if (present.scope) setAccountRaw(LS.plexLibrary, state.scope);
else setScopeState(readPlexScope());
if (present.show) setAccountRaw(LS.plexShow, state.show);
else setShowState(readPlexShow());
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort));
else setSortState(readPlexSort());
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters));
else setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ());
setFieldState(readPlexField());
return;
}
setScopeState(readPlexScope());
setShowState(readPlexShow());
setSortState(readPlexSort());
setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ());
setFieldState(readPlexField());
});
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
// later plain reload. The seed's base was the stored view, so a dimension the link omitted is written
// back unchanged. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
useEffect(() => {
if (!urlSeed) return;
setAccountRaw(LS.plexLibrary, urlSeed.scope);
setAccountRaw(LS.plexShow, urlSeed.show);
setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
}, [urlSeed]);
const setScope = useCallback(
(v: string) => {
markDirty();
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
},
[markDirty],
);
const setShow = useCallback(
(v: string) => {
markDirty();
setShowState(v);
setAccountRaw(LS.plexShow, v);
},
[markDirty],
);
const setSort = useCallback(
(v: SortState) => {
markDirty();
setSortState(v);
setAccountRaw(LS.plexSort, JSON.stringify(v));
},
[markDirty],
);
const setFilters = useCallback(
(f: PlexFilters) => {
markDirty();
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
},
[markDirty],
);
const setQ = useCallback(
(v: string) => {
markDirty();
setQState(v);
setAccountRaw(LS.plexQ, v);
},
[markDirty],
);
const setField = useCallback(
(v: PlexSearchField) => {
markDirty();
setFieldState(v);
setAccountRaw(LS.plexField, v);
},
[markDirty],
);
const setScope = useCallback((v: string) => {
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
}, []);
const setShow = useCallback((v: string) => {
setShowState(v);
setAccountRaw(LS.plexShow, v);
}, []);
const setSort = useCallback((v: SortState) => {
setSortState(v);
setAccountRaw(LS.plexSort, JSON.stringify(v));
}, []);
const setFilters = useCallback((f: PlexFilters) => {
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
}, []);
const setQ = useCallback((v: string) => {
setQState(v);
setAccountRaw(LS.plexQ, v);
}, []);
const setField = useCallback((v: PlexSearchField) => {
setFieldState(v);
setAccountRaw(LS.plexField, v);
}, []);
const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
+33 -45
View File
@@ -3,14 +3,15 @@ import {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { useQueryClient } from "@tanstack/react-query";
import { api, type Me } from "../lib/api";
import {
applyTheme,
DEFAULT_THEME,
@@ -53,35 +54,47 @@ export function usePrefs(): PrefsController {
}
export function PrefsProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
// The last server-known prefs. Written on adopt + after each save so the auto-save effect can
// compare against it and avoid echoing a server-adopted value straight back. A ref (drives no UI).
const baselineRef = useRef<EditablePrefs>({
theme: loadLocalTheme(),
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
const qc = useQueryClient();
// The account's prefs, seeded straight from the `me` cache — BootGate mounts this only once /api/me
// has answered, so a theme changed on another device is right on the first paint instead of
// switching after it. This browser's copy fills any key the server has never stored.
const [seed] = useState<EditablePrefs>(() => {
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
return {
theme: prefs?.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
performanceMode:
typeof prefs?.performanceMode === "boolean"
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs?.notifications
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
};
});
const [theme, setThemeState] = useState<ThemePrefs>(seed.theme);
const [perf, setPerf] = useState(seed.performanceMode);
const [hints, setHints] = useState(seed.hints);
const [notif, setNotif] = useState<NotifSettings>(seed.notifications);
// The last server-known prefs: the seed, then each successful save. The auto-save effect compares
// against it, so the seed itself is never echoed back as a save. A ref (drives no UI).
const baselineRef = useRef<EditablePrefs>(seed);
const [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = useRef<number | undefined>(undefined);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// Live-apply the prefs locally for instant preview (their localStorage mirrors update via the
// stores too); persistence to the server is the auto-save effect below.
useEffect(() => applyTheme(theme), [theme]);
useEffect(() => {
// stores too); persistence to the server is the auto-save effect below. The look is applied in
// LAYOUT effects, before the browser paints, so the app's first frame is already in the account's
// theme rather than in the baseline one main.tsx sets for the boot screen.
useLayoutEffect(() => applyTheme(theme), [theme]);
useLayoutEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
// theme-appropriate image on <body> and switches the glass to translucent.
useEffect(() => {
useLayoutEffect(() => {
const on = theme.bgImage && !perf;
document.documentElement.dataset.backdrop = on ? "on" : "off";
}, [theme.bgImage, perf]);
@@ -132,31 +145,6 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
[],
);
// On login / account switch, adopt the server-stored prefs into the live states AND the auto-save
// baseline (so adopting them doesn't echo a save back).
useEffect(() => {
const prefs = me?.preferences;
if (!prefs) return;
const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
};
setThemeState(adopted.theme);
saveLocalTheme(adopted.theme);
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
baselineRef.current = adopted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id]);
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
const setTheme = useCallback((next: ThemePrefs) => {
setThemeState(next);
@@ -43,7 +43,7 @@ vi.mock("./PlexProvider", () => ({
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
import { api, setActiveAccount, type SavedView } from "../lib/api";
import { api, type SavedView } from "../lib/api";
import { LS, accountKey } from "../lib/storage";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
@@ -109,21 +109,6 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
});
it("feed: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => {
// The provider mounts above <App>, which pins the tab-account only after /api/me; the ungated
// views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its
// one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload).
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
await mountResolved([
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
]);
expect(h.setFeedFilters).not.toHaveBeenCalled();
// App pins the account; the reactive id re-fires the effect and the deferred seed lands.
act(() => setActiveAccount(ID));
await waitFor(() => expect(h.setFeedFilters).toHaveBeenCalledTimes(1));
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
});
it("feed: does NOT seed when the account already has stored filters", async () => {
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
+5 -12
View File
@@ -25,7 +25,6 @@ import { filtersToParams, hasFilterParams } from "../lib/urlState";
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
import { notifyActionError } from "../lib/actionErrors";
import { accountKey, LS } from "../lib/storage";
import { useActiveAccountId } from "../lib/activeAccount";
import { useConfirm } from "./ConfirmProvider";
import { useNavigation } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
@@ -179,19 +178,13 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
// 60s background refetch never re-seeds over the filters you're editing.
//
// WAIT FOR THE ACCOUNT PIN. This provider mounts above <App>, which pins the tab-account only after
// /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null.
// canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored")
// with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never
// re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the
// pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real.
const accountId = useActiveAccountId();
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
// before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set());
useEffect(() => {
const data = viewsQuery.data;
if (!data || accountId == null || seededRef.current.has(source)) return;
if (!data || seededRef.current.has(source)) return;
seededRef.current.add(source);
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
if (!def) return;
@@ -200,7 +193,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
// cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewsQuery.data, source, accountId]);
}, [viewsQuery.data, source]);
const currentKey = cfg.keyOf(cfg.filters);
const activeView = useMemo(
+3 -4
View File
@@ -131,10 +131,9 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
// The landing page (E24). NOT a server preference like the rest of this tab: `loadInitialPage`
// has to answer before /api/me lands, so the value must be readable synchronously from
// localStorage — a landing module decided after the first paint would be a visible jump. The two
// stored values come from navigationInit, which is what reads them back at boot.
// The landing page (E24). Per-account localStorage, not a server preference like the rest of this
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
// values come from navigationInit, which is what reads them back at boot.
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
return (
+3 -3
View File
@@ -22,8 +22,8 @@ const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
//
// The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from
// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (App owns the
// fetching query), so it re-runs the auto-open check when the account resolves or its grants change.
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (BootGate owns
// the fetching query), so it re-runs the auto-open check when the account's grants change.
interface WizardActions {
openWizard: () => void;
@@ -43,7 +43,7 @@ export function useWizard(): WizardActions {
export function WizardProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
// Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const openWizard = useCallback(() => setOpen(true), []);
+36 -21
View File
@@ -1,4 +1,5 @@
import { lazy, type ComponentType } from "react";
import type { ComponentType } from "react";
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
import type { Page } from "../lib/urlState";
import type { PanelId, PanelLayout } from "../lib/panelLayout";
import { useMe } from "../lib/useMe";
@@ -21,22 +22,24 @@ import { useSavedViews } from "./SavedViewsProvider";
// its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
const Home = lazy(() => import("./Home"));
const Feed = lazy(() => import("./Feed"));
const Channels = lazy(() => import("./Channels"));
const Playlists = lazy(() => import("./Playlists"));
const PlexBrowse = lazy(() => import("./PlexBrowse"));
const PlexSidebar = lazy(() => import("./PlexSidebar"));
const NotificationsPanel = lazy(() => import("./NotificationsPanel"));
const Messages = lazy(() => import("./Messages"));
const DownloadCenter = lazy(() => import("./DownloadCenter"));
const Stats = lazy(() => import("./Stats"));
const Scheduler = lazy(() => import("./Scheduler"));
const ConfigPanel = lazy(() => import("./ConfigPanel"));
const AdminUsers = lazy(() => import("./AdminUsers"));
const AuditLog = lazy(() => import("./AuditLog"));
const SettingsPanel = lazy(() => import("./SettingsPanel"));
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>. Each
// one is preloadable: App fetches the chunks of every page the account can reach once the first page
// is on screen (preloadPages below), so switching modules never shows the chunk fallback.
const Home = lazyWithPreload(() => import("./Home"));
const Feed = lazyWithPreload(() => import("./Feed"));
const Channels = lazyWithPreload(() => import("./Channels"));
const Playlists = lazyWithPreload(() => import("./Playlists"));
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
const Messages = lazyWithPreload(() => import("./Messages"));
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
const Stats = lazyWithPreload(() => import("./Stats"));
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
const AuditLog = lazyWithPreload(() => import("./AuditLog"));
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
@@ -80,8 +83,9 @@ function MessagesPage() {
}
export type PageDef = {
// Prop-free by construction: reads `me` + module state from hooks/providers.
Component: ComponentType;
// Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so
// a new page cannot silently opt out of preloadPages.
Component: PreloadableComponent<object>;
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
fadeTop?: boolean;
@@ -94,12 +98,13 @@ export type PageDef = {
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
home: { Component: Home, maxWidth: "80rem" },
feed: { Component: FeedPage },
// The two wrappers preload the lazy chunk they render.
feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) },
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
plex: { Component: PlexBrowse, maxWidth: "1600px" },
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
messages: { Component: MessagesPage },
messages: { Component: Object.assign(MessagesPage, { preload: Messages.preload }) },
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
@@ -134,3 +139,13 @@ export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
};
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
export function preloadPages(pages: Page[]): void {
for (const p of pages) {
PAGE_CONTENT[p].Component.preload().catch(() => {});
const rail = PAGE_RAIL[p]?.Rail as Partial<PreloadableComponent<RailProps>> | undefined;
rail?.preload?.().catch(() => {});
}
}
@@ -6,11 +6,17 @@ import { Button } from "./Button";
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
// visual for a page/section's first load, so all of them read the same (and can be swapped for
// skeletons later in one place). `role=status`/`aria-live` announces it to screen readers.
//
const LOADING_REVEAL_DELAY = "300ms";
// It fades in only after LOADING_REVEAL_DELAY: most first loads here answer in tens of milliseconds,
// and a spinner shown for two frames before the content replaces it reads as a flicker, not as
// feedback. A load that really takes a while still gets the spinner, a third of a second in.
export function LoadingState({ label, className = "" }: { label?: ReactNode; className?: string }) {
const { t } = useTranslation();
return (
<div
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
style={{ animation: `fadeIn 0.2s ease ${LOADING_REVEAL_DELAY} both` }}
role="status"
aria-live="polite"
>
+1 -1
View File
@@ -32,7 +32,7 @@ export function isSupported(code: string | null | undefined): code is LangCode {
}
// Initial language before the server preference is known: stored choice, else the browser
// language, else English. (After login, App adopts the server-persisted preference.)
// language, else English. (Signed in, BootGate adopts the account's preference before the app renders.)
function detectInitialLang(): LangCode {
const stored = localStorage.getItem(LANG_KEY);
if (isSupported(stored)) return stored;
+7 -25
View File
@@ -1,9 +1,10 @@
import { useSyncExternalStore } from "react";
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts), plus its reactive
// wrapper. Its OWN module (imports nothing internal) so it can be shared by both `storage.ts` (which
// keys per-account localStorage on it) and `useAccountRehydrate.ts` without a circular import — the
// account id is a lower-level primitive than the storage helpers that consume it.
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Its OWN module
// (imports nothing internal) so it can be shared by `storage.ts`, which keys per-account localStorage
// on it, without a circular import — the account id is a lower-level primitive than the storage
// helpers that consume it.
//
// Deliberately NOT reactive: BootGate pins the account before any per-account state is read, and a
// switch reloads the page, so nothing mounted ever sees the id change underneath it.
//
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
@@ -29,22 +30,3 @@ export function withActiveAccount(path: string): string {
if (id == null) return path;
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
}
// Reactive active-account. setActiveAccount/clearActiveAccount write sessionStorage — a silent op that
// triggers no React render — so this tab's per-account state (sort/filter/view/layout, all keyed by the
// account) would never re-read once the account is finally known. That bites exactly once, on first
// load: the provider tree above <App> mounts with NO account, then <App> pins it after /api/me resolves.
// The account setters call notifyActiveAccountChange() so useActiveAccountId subscribers can re-hydrate.
const accountListeners = new Set<() => void>();
function subscribeActiveAccount(cb: () => void): () => void {
accountListeners.add(cb);
return () => accountListeners.delete(cb);
}
export function notifyActiveAccountChange(): void {
for (const cb of [...accountListeners]) cb();
}
/** The current tab's active account id, as reactive state — re-renders when it is (re)pinned. The
* snapshot is a primitive (number | null), so useSyncExternalStore's Object.is check is stable. */
export function useActiveAccountId(): number | null {
return useSyncExternalStore(subscribeActiveAccount, activeAccountId, activeAccountId);
}
-1
View File
@@ -19,7 +19,6 @@ vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" }
vi.mock("../activeAccount", () => ({
activeAccountId: () => null,
ACTIVE_ACCOUNT_KEY: "k",
notifyActiveAccountChange: vi.fn(),
}));
import {
+1 -4
View File
@@ -6,7 +6,7 @@ import { getActiveToasts, notify, remove } from "../notifications";
import i18n from "../../i18n";
import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../activeAccount";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
@@ -268,8 +268,6 @@ export function setActiveAccount(id: number): void {
} catch {
/* ignore */
}
// Let per-account UI state re-hydrate now the account is known (see notifyActiveAccountChange).
notifyActiveAccountChange();
}
export function clearActiveAccount(): void {
try {
@@ -277,7 +275,6 @@ export function clearActiveAccount(): void {
} catch {
/* ignore */
}
notifyActiveAccountChange();
}
export async function req<T = unknown>(
@@ -0,0 +1,69 @@
// @vitest-environment jsdom
//
// lazyWithPreload: once preloaded, a page renders synchronously — no Suspense fallback, which on a module
// switch is a "Loading…" flash in place of the page. And a page that mounted through the lazy path is
// not remounted (its state kept) once the module is loaded.
import { describe, it, expect, afterEach } from "vitest";
import { Suspense, useState, type ComponentType } from "react";
import { render, screen, act, fireEvent, cleanup } from "@testing-library/react";
import { lazyWithPreload } from "./lazyWithPreload";
function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>count {n}</button>;
}
const module = (): Promise<{ default: ComponentType<object> }> =>
Promise.resolve({ default: Counter });
const inSuspense = (C: ComponentType) => (
<Suspense fallback={<span>fallback</span>}>
<C />
</Suspense>
);
afterEach(cleanup);
describe("lazyWithPreload", () => {
it("renders a preloaded page on its first render, without the fallback", async () => {
const Page = lazyWithPreload(module);
await Page.preload();
render(inSuspense(Page));
// Synchronously after render — no act/await, so a suspension would still be showing its fallback.
expect(screen.queryByText("fallback")).toBeNull();
expect(screen.getByText("count 0")).toBeTruthy();
});
it("without a preload it suspends once, then renders", async () => {
const Page = lazyWithPreload(module);
render(inSuspense(Page));
expect(screen.getByText("fallback")).toBeTruthy();
expect(await screen.findByText("count 0")).toBeTruthy();
});
it("a page mounted through the lazy path keeps its state across later renders", async () => {
// Mounted into a boundary that is ALREADY showing content — how a module switch mounts a page.
const Page = lazyWithPreload(module);
const shell = (show: boolean, tick: number) => (
<Suspense fallback={<span>fallback</span>}>
<i>shell {tick}</i>
{show && <Page />}
</Suspense>
);
const { rerender } = render(shell(false, 0));
rerender(shell(true, 0));
fireEvent.click(await screen.findByText("count 0")); // the module has loaded by now
rerender(shell(true, 1)); // a later render of the same mount
expect(screen.getByText("count 1")).toBeTruthy(); // not remounted as the loaded component
});
it("retries a preload that failed", async () => {
let calls = 0;
const Page = lazyWithPreload(() =>
++calls === 1
? Promise.reject<{ default: ComponentType<object> }>(new Error("offline"))
: module(),
);
await expect(Page.preload()).rejects.toThrow("offline");
await act(() => Page.preload());
expect(calls).toBe(2);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { createElement, lazy, type ComponentType } from "react";
// A route-level lazy component whose code can be fetched BEFORE its first render.
//
// React.lazy alone suspends on every first render, even when the chunk is already in the browser: the
// module promise settles a tick later, so the Suspense fallback is committed and painted for a frame or
// several. On a module switch that is a "Loading…" flash in place of the page, on every first visit.
// Here, once `preload()` has resolved, the component renders the loaded module synchronously and never
// suspends at all.
//
// Choosing per render (`loaded ?? Lazy`) cannot swap a mounted page's component type and remount it:
// a render that suspends on `Lazy` is never committed (React discards a suspended new mount), and
// `loaded` is set before `Lazy`'s own promise resolves — so every committed render of a mount sees the
// loaded module. Measured, not assumed: the state test in lazyWithPreload.dom.test.tsx stayed green
// with a per-mount pin removed, in a boundary that was already showing content.
export type PreloadableComponent<P extends object> = ComponentType<P> & {
/** Fetch the component's code now. Idempotent; a failed fetch is retried by the next call. */
preload: () => Promise<void>;
};
export function lazyWithPreload<P extends object>(
factory: () => Promise<{ default: ComponentType<P> }>,
): PreloadableComponent<P> {
let loaded: ComponentType<P> | null = null;
let pending: Promise<void> | null = null;
const preload = () =>
(pending ??= factory().then(
(m) => {
loaded = m.default;
},
(err: unknown) => {
pending = null;
throw err;
},
));
const Lazy = lazy(() => preload().then(() => ({ default: loaded as ComponentType<P> })));
function Preloadable(props: P) {
return createElement(loaded ?? (Lazy as unknown as ComponentType<P>), props);
}
return Object.assign(Preloadable, { preload });
}
+12 -20
View File
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { activeAccountId } from "./activeAccount";
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
@@ -16,15 +15,13 @@ export const LS = {
filters: "siftlode.filters",
page: "siftlode.page",
// E24: start every session on the Home surface rather than on the module you happened to leave
// open. Per-account, "0" = opt out and go back to remembering the last module. It has to be
// localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands —
// a landing page decided after the first paint would be a visible jump.
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's
// account is pinned — so the landing is decided right before the first paint, never corrected after.
startOnHome: "siftlode.startOnHome",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// established tab paints your view straight away. A brand-new tab still starts on the default
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
// the per-account key can't be read.
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
// from only when the server has never stored the key.
feedView: "siftlode.feedView",
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
@@ -72,7 +69,7 @@ export const LS = {
} as const;
// --- per-account scoping -----------------------------------------------------------------
// The active-account primitive (activeAccountId / useActiveAccountId / notify) lives in ./activeAccount
// The active-account primitive (activeAccountId) lives in ./activeAccount
// so it can be shared without a cycle; the helpers below key localStorage on it.
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
@@ -154,24 +151,21 @@ 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. 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. */
* can be used before an async-loaded option list is known, keeping hook order stable. Read once, at
* mount: BootGate mounts every consumer after the account is pinned. */
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));
// Stable setter (base + markDirty are both stable) so a provider can build a stable action on top
// of it — e.g. ChannelsProvider's setView, which must not change identity on every tab switch.
// Stable setter (base is stable) so a provider can build a stable action on top of it — e.g.
// ChannelsProvider's setView, which must not change identity on every tab switch.
const set = useCallback(
(next: string) => {
markDirty();
setValue(next);
setAccountRaw(base, next);
},
[base, markDirty],
[base],
);
return [value, set];
}
@@ -184,17 +178,15 @@ 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, markDirty],
[base],
);
return [value, patch];
}
@@ -1,45 +0,0 @@
// @vitest-environment jsdom
//
// useAccountRehydrate re-reads per-account state when the tab's account is pinned after mount (first
// load). useActiveAccountId is mocked with a plain variable so the transition can be driven by hand.
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
let account: number | null = null;
vi.mock("./activeAccount", () => ({
useActiveAccountId: () => account,
}));
import { useAccountRehydrate } from "./useAccountRehydrate";
describe("useAccountRehydrate", () => {
beforeEach(() => {
account = null;
});
it("runs rehydrate when the account transitions from null to a value", () => {
const rehydrate = vi.fn();
const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
expect(rehydrate).not.toHaveBeenCalled(); // mount with no account: no transition
account = 1;
act(() => rerender());
expect(rehydrate).toHaveBeenCalledTimes(1);
});
it("does not run when the account id is already known and stable", () => {
account = 1;
const rehydrate = vi.fn();
const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
act(() => rerender());
expect(rehydrate).not.toHaveBeenCalled();
});
it("skips rehydrate when marked dirty before the account pins (no clobber of a user edit)", () => {
const rehydrate = vi.fn();
const { result, rerender } = renderHook(() => useAccountRehydrate(rehydrate));
act(() => result.current()); // markDirty — the owning setter ran pre-pin
account = 1;
act(() => rerender());
expect(rehydrate).not.toHaveBeenCalled();
});
});
-39
View File
@@ -1,39 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import { useActiveAccountId } from "./activeAccount";
// Re-read per-account persisted state when this tab's active account becomes known (or changes). The
// provider tree above <App> mounts before the account is pinned, so its useState initializers seed from
// an unknown account (empty defaults); once <App> pins it, `rehydrate` runs to load the real stored
// value. Guarded so it never clobbers a value the user set in the brief pre-pin window: the returned
// `markDirty` is called by the owning setter, and a dirty state skips the re-hydrate.
//
// Keyed on the account id ONLY — the user's own writes go through setAccountRaw, which does not change
// the account id, so this effect can't loop on them.
//
// This is the localStorage-only counterpart of the `useEffect([me?.id])` server-prefs adopt pattern in
// FeedFiltersProvider / FeedViewProvider / PrefsProvider (and App's shell prefs): those re-load from the
// SERVER when the account query resolves; this re-loads from localStorage when the account is pinned.
// A provider needs one or the other — not both — to survive a fresh tab's first paint.
export function useAccountRehydrate(rehydrate: () => void): () => void {
const accountId = useActiveAccountId();
const dirty = useRef(false);
const prev = useRef(accountId);
// Latest closure in a ref so the effect depends on the account id alone (a fresh `rehydrate` each
// render must not re-fire it), while still calling the current logic.
const fn = useRef(rehydrate);
fn.current = rehydrate;
useEffect(() => {
if (accountId === prev.current) return; // mount, and renders with no real transition
prev.current = accountId;
if (!dirty.current) fn.current();
// Reset per account: `dirty` means "the user edited under THIS account". Today a real account
// switch full-reloads (fresh instance), but clearing it here keeps the guard correct even if a
// future switch stops reloading — the next account starts clean rather than inheriting dirtiness.
dirty.current = false;
}, [accountId]);
return useCallback(() => {
dirty.current = true;
}, []);
}
+3 -48
View File
@@ -6,26 +6,19 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
const { store, ctl } = vi.hoisted(() => ({
store: new Map<string, string>(),
ctl: { account: 1 as number | null },
}));
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
vi.mock("./storage", () => ({
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
getAccountRaw: (k: string) => store.get(k) ?? null,
setAccountRaw: (k: string, v: string) => {
if (ctl.account != null) store.set(k, v);
store.set(k, v);
},
}));
vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
import { useFilterState } from "./useFilterState";
describe("useFilterState", () => {
beforeEach(() => {
store.clear();
ctl.account = 1;
});
it("hydrates to an empty map when storage is empty", () => {
@@ -77,42 +70,4 @@ describe("useFilterState", () => {
// A seed that returns null falls back to an empty map.
expect(renderHook(() => useFilterState("k3", () => null)).result.current[0]).toEqual({});
});
// The account-pin re-hydrate (via useAccountRehydrate) integrated into useFilterState.
it("re-hydrates the stored map once the account is pinned after mount", () => {
ctl.account = null; // fresh tab: no account at mount
store.set("k", JSON.stringify({ sync: "fully", tags: ["7"] }));
const { result, rerender } = renderHook(() => useFilterState("k"));
expect(result.current[0]).toEqual({}); // mount read with no account → empty
act(() => {
ctl.account = 1; // <App> pins the account
rerender();
});
expect(result.current[0]).toEqual({ sync: "fully", tags: ["7"] });
});
it("applies the migrate seed on the account-pin re-hydrate when the key is still empty", () => {
ctl.account = null;
// Mirrors migrateSyncChip: reads an account-scoped legacy key, so it yields null until pinned.
const migrate = () => (ctl.account == null ? null : { sync: "needsFull" });
const { result, rerender } = renderHook(() => useFilterState("k", migrate));
expect(result.current[0]).toEqual({}); // migrate can't read the legacy key with no account yet
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ sync: "needsFull" }); // migrate carries the legacy value forward
});
it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
ctl.account = null;
store.set("k", JSON.stringify({ sync: "fully" })); // the stored value
const { result, rerender } = renderHook(() => useFilterState("k"));
act(() => result.current[1]({ sync: "needsFull" })); // user edits before the pin
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ sync: "needsFull" }); // the edit survives, not "fully"
});
});
+4 -10
View File
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { getAccountRaw, setAccountRaw } from "./storage";
import { useAccountRehydrate } from "./useAccountRehydrate";
import type { FilterMap } from "./tableColumns";
// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the
@@ -14,22 +13,17 @@ export function useFilterState(
// flash); it stays idempotent because the seed persists as soon as the user touches the filters.
migrate?: () => FilterMap | null,
): [FilterMap, (next: FilterMap) => void] {
// Read the account's stored map, or the migrate seed when its key is still empty. Shared by the
// initial mount AND the account-pin re-hydrate, so first-load migration runs on whichever fires with
// the account known (on a fresh tab the account is null at mount → the re-hydrate does the real read).
const read = useCallback((): FilterMap => {
// The account's stored map, or the migrate seed when its key is still empty.
const [filters, setFilters] = useState<FilterMap>(() => {
const raw = getAccountRaw(storageBase);
return raw ? hydrate(raw) : (migrate?.() ?? {});
}, [storageBase, migrate]);
const [filters, setFilters] = useState<FilterMap>(read);
const markDirty = useAccountRehydrate(() => setFilters(read()));
});
const set = useCallback(
(next: FilterMap) => {
markDirty();
setFilters(next);
setAccountRaw(storageBase, JSON.stringify(next));
},
[storageBase, markDirty],
[storageBase],
);
return [filters, set];
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type Me } from "./api";
// The signed-in account, read from the qk.me() query cache. App owns the FETCHING query (gated on
// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the
// The signed-in account, read from the qk.me() query cache. BootGate owns the FETCHING query (gated on
// setup state) and App renders modules only once it has resolved to a real user — so anywhere inside the
// authenticated module tree the cache is always populated. `enabled:false` makes this a passive
// observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache
// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook
+2 -15
View File
@@ -2,10 +2,10 @@
//
// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache /
// localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror),
// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the
// debounced writes, parse, and the raw useSavePref persister. The `me` cache is the
// server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
@@ -125,19 +125,6 @@ describe("usePref", () => {
expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" });
});
it("adopts the server value when the account id changes (login / switch)", async () => {
const qc = makeClient();
seedMe(qc, { view: "cards" }, 1);
const { result } = renderHook(() => usePref<string>("view", "cards"), {
wrapper: wrapperFor(qc),
});
expect(result.current[0]).toBe("cards");
// A different account resolves. The passive me observer notifies asynchronously (react-query's
// notifyManager batches), so wait for the re-render — the same path the real App drives on login.
act(() => seedMe(qc, { view: "tiles" }, 2));
await waitFor(() => expect(result.current[0]).toBe("tiles"));
});
describe("debounceMs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
+12 -34
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api } from "./api";
import type { Me } from "./api/account";
@@ -14,24 +14,19 @@ const reportPrefSaveFailure = (err: unknown) =>
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its
// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache,
// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds
// all three into one hook so a new server-synced pref is a one-liner.
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and
// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new
// server-synced pref is a one-liner.
//
// Two mirror strategies, unified here (pick via `localKey`):
// • Providers mounted ABOVE <App> (feed view, panel layouts, rail-collapse flags) render before
// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on
// reload — pass `localKey`.
// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they
// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`.
// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other
// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the
// app tree only once /api/me has answered, keyed on the account, so there is nothing to adopt after
// mount. The source of truth is the server; the `me` cache is kept in sync optimistically so other
// consumers reading the same key (e.g. a second player instance) see the change at once.
export interface UsePrefOptions<T> {
/** Also mirror to per-account localStorage under this base key, and seed synchronously from it on
* first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
/** Also mirror to per-account localStorage under this base key; that copy seeds the value when the
* server payload lacks the key. */
localKey?: string;
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
* local state + cache still update instantly; only the network write is deferred. */
@@ -70,8 +65,8 @@ export function useSavePref(): (patch: Record<string, unknown>) => void {
);
}
/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with
* automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */
/** A per-account preference persisted to the server (and optionally mirrored to localStorage).
* Returns `[value, setValue]`; the setter is stable. */
export function usePref<T>(
serverKey: string,
defaultValue: T,
@@ -79,8 +74,6 @@ export function usePref<T>(
): [T, (next: T) => void] {
const { localKey, debounceMs, parse } = options;
const qc = useQueryClient();
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
// Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which
// would churn the memoized setter and the adopt effect). parse must be idempotent anyway.
@@ -141,20 +134,5 @@ export function usePref<T>(
[qc, serverKey, localKey, debounceMs],
);
// Adopt the server value when the account becomes known / changes (login, account switch). Guarded on
// the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key →
// keep the current (seeded / user-set) value rather than clobbering it with the default.
const prevId = useRef<number | null | undefined>(me?.id);
useEffect(() => {
if (me?.id === prevId.current) return;
prevId.current = me?.id;
const server = readServerPref(me, serverKey);
if (server === undefined) return;
const val = norm(server);
setValue(val);
if (localKey) writeAccount(localKey, val);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id]);
return [value, set];
}
+3 -35
View File
@@ -6,26 +6,19 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
const { store, ctl } = vi.hoisted(() => ({
store: new Map<string, string>(),
ctl: { account: 1 as number | null },
}));
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
vi.mock("./storage", () => ({
getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
getAccountRaw: (k: string) => store.get(k) ?? null,
setAccountRaw: (k: string, v: string) => {
if (ctl.account != null) store.set(k, v);
store.set(k, v);
},
}));
vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
import { useSortState } from "./useSortState";
describe("useSortState", () => {
beforeEach(() => {
store.clear();
ctl.account = 1;
});
it("hydrates to the fallback when storage is empty", () => {
@@ -62,29 +55,4 @@ describe("useSortState", () => {
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toEqual({ key: "anything", dir: "desc" });
});
// The account-pin re-hydrate (via useAccountRehydrate) integrated into useSortState.
it("re-hydrates the stored sort once the account is pinned after mount", () => {
ctl.account = null; // fresh tab: no account at mount
store.set("k", JSON.stringify({ key: "views", dir: "desc" }));
const { result, rerender } = renderHook(() => useSortState("k"));
expect(result.current[0]).toBeNull(); // mount read with no account → default
act(() => {
ctl.account = 1; // <App> pins the account
rerender();
});
expect(result.current[0]).toEqual({ key: "views", dir: "desc" });
});
it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
ctl.account = null;
store.set("k", JSON.stringify({ key: "views", dir: "desc" })); // the stored value
const { result, rerender } = renderHook(() => useSortState("k"));
act(() => result.current[1]({ key: "name", dir: "asc" })); // user edits before the pin
act(() => {
ctl.account = 1;
rerender();
});
expect(result.current[0]).toEqual({ key: "name", dir: "asc" }); // the edit survives, not "views"
});
});
+1 -7
View File
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { getAccountRaw, setAccountRaw } from "./storage";
import { useAccountRehydrate } from "./useAccountRehydrate";
import { hydrateSort, type SortOption, type SortState } from "./columnSort";
// The per-account persisted sort state, hydrated + healed against the current options — the plumbing
@@ -14,17 +13,12 @@ export function useSortState(
const [sort, setSort] = useState<SortState>(() =>
hydrateSort(getAccountRaw(storageBase), options, fallback),
);
// When the account is pinned after mount (first load), re-read the stored sort for that account.
const markDirty = useAccountRehydrate(() =>
setSort(hydrateSort(getAccountRaw(storageBase), options, fallback)),
);
const set = useCallback(
(next: SortState) => {
markDirty();
setSort(next);
setAccountRaw(storageBase, JSON.stringify(next));
},
[storageBase, markDirty],
[storageBase],
);
return [sort, set];
}
+37 -23
View File
@@ -5,6 +5,7 @@ import { createQueryClient } from "./lib/queryClient";
import { FloatingTree } from "@floating-ui/react";
import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import BootGate, { BootScreen } from "./components/BootGate";
import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { SavedViewsProvider } from "./components/SavedViewsProvider";
@@ -24,7 +25,8 @@ import "flag-icons/css/flag-icons.min.css";
// The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers
// can fetch them directly; the watch page is a login-free public player. Reached by full-page
// navigation (plain <a href>), so a simple pathname switch is enough — no router needed.
const App = lazy(() => import("./App"));
const loadApp = () => import("./App");
const App = lazy(loadApp);
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
const Terms = lazy(() => import("./components/legal/Terms"));
const WatchPage = lazy(() => import("./components/WatchPage"));
@@ -39,6 +41,10 @@ de.theme ||= "dark";
de.scheme ||= "midnight";
const path = window.location.pathname;
const isPublicPage = path === "/privacy" || path === "/terms" || path.startsWith("/watch/");
// Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens:
// the two waits then overlap rather than queue, and the gate usually opens straight onto the app.
if (!isPublicPage) void loadApp();
const root =
path === "/privacy" ? (
<PrivacyPolicy />
@@ -56,29 +62,37 @@ const root =
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
<FloatingTree>
<ConfirmProvider>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<PlexViewBridge>
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
{/* Nothing below renders until the tab knows which account it is: every provider
seeds per-account state in its initializer (see BootGate). */}
<BootGate>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<PlexViewBridge>
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
source (feed ⟷ plex) by the active page and read the right live filters. */}
<SavedViewsProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</SavedViewsProvider>
</PlexViewBridge>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
<SavedViewsProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
{/* The same quiet screen as the gate's, so a chunk that is still
loading when the gate opens continues it instead of blanking. */}
<Suspense fallback={<BootScreen />}>
<App />
</Suspense>
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</SavedViewsProvider>
</PlexViewBridge>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</BootGate>
</ConfirmProvider>
</FloatingTree>
</ErrorBoundary>