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:
+46
-78
@@ -2,9 +2,9 @@ import { lazy, Suspense, useEffect, useState } from "react";
|
|||||||
import { qk } from "./lib/queryKeys";
|
import { qk } from "./lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { notifyActionError } from "./lib/actionErrors";
|
||||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
import { setLanguage, type LangCode } from "./i18n";
|
||||||
import { clearParams } from "./lib/urlState";
|
import { clearParams } from "./lib/urlState";
|
||||||
import type { Page } from "./lib/urlState";
|
import type { Page } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +24,9 @@ import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersP
|
|||||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||||
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
||||||
import { moduleDef, moduleOrder } from "./lib/modules";
|
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.
|
// Persistent shell — always mounted, so eagerly imported.
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
import SetupWizard from "./components/SetupWizard";
|
import SetupWizard from "./components/SetupWizard";
|
||||||
@@ -38,7 +40,6 @@ import Toaster from "./components/ui/Toaster";
|
|||||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||||
import AppCommandPalette from "./components/AppCommandPalette";
|
import AppCommandPalette from "./components/AppCommandPalette";
|
||||||
import { useGlobalKeymap } from "./lib/keymap";
|
import { useGlobalKeymap } from "./lib/keymap";
|
||||||
import { StateMessage } from "./components/ui/QueryState";
|
|
||||||
import ErrorDialog from "./components/ErrorDialog";
|
import ErrorDialog from "./components/ErrorDialog";
|
||||||
import VersionBanner from "./components/VersionBanner";
|
import VersionBanner from "./components/VersionBanner";
|
||||||
import DemoBanner from "./components/DemoBanner";
|
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
|
// 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
|
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
||||||
// two modals, which sit outside the registry-driven content column.
|
// 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 About = lazy(() => import("./components/About"));
|
||||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
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>.
|
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
||||||
const view = useFeedView();
|
const view = useFeedView();
|
||||||
const { setView } = useFeedViewActions();
|
const { setView } = useFeedViewActions();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
|
// 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.
|
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
|
||||||
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
|
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
|
||||||
feed: loadLayout("feed"),
|
// changed on another device is right on the first paint; the local copy covers a key the server
|
||||||
plex: loadLayout("plex"),
|
// has never stored.
|
||||||
playlists: loadLayout("playlists"),
|
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 [aboutOpen, setAboutOpen] = useState(false);
|
||||||
const [notesOpen, setNotesOpen] = useState(false);
|
const [notesOpen, setNotesOpen] = useState(false);
|
||||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
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
|
// (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.)
|
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
// Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls
|
||||||
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
|
// and waits for: App mounts only once both have answered, so their caches are always populated here.
|
||||||
// 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).
|
|
||||||
const setupQuery = useQuery({
|
const setupQuery = useQuery({
|
||||||
queryKey: qk.setupStatus(),
|
queryKey: qk.setupStatus(),
|
||||||
queryFn: api.setupStatus,
|
queryFn: api.setupStatus,
|
||||||
staleTime: Infinity,
|
enabled: false,
|
||||||
});
|
});
|
||||||
const needsSetup = setupQuery.data?.configured === false;
|
const needsSetup = setupQuery.data?.configured === false;
|
||||||
// Poll the session so an account suspended/deleted by an admin is noticed even with no
|
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||||
// 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),
|
|
||||||
});
|
|
||||||
|
|
||||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
// 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
|
// 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"] : [],
|
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||||
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||||
// tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
|
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
||||||
// tab always sends its own account header, so cross-tab changes can't reach it. An explicit
|
// requestIdleCallback, hence the timer).
|
||||||
// switch on THIS tab sets the override itself (so this no-ops then).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
const me = meQuery.data;
|
||||||
}, [meQuery.data?.id]);
|
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
|
// 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
|
// 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");
|
clearParams("admin");
|
||||||
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [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
|
// 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
|
// 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).
|
// 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.
|
// 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 (needsSetup) return <SetupWizard />;
|
||||||
|
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
|
||||||
if (meQuery.isLoading)
|
// probe — so no data here means "not signed in" → the public landing.
|
||||||
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>
|
|
||||||
);
|
|
||||||
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
|
if (!meQuery.data) return <Welcome verify={verifyEntry} />;
|
||||||
|
|
||||||
// Shown briefly while a lazy page/module chunk loads (first visit to that page only).
|
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
|
||||||
const pageFallback = (
|
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
|
||||||
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
|
const pageFallback = <LoadingState className="flex-1 justify-center" />;
|
||||||
);
|
|
||||||
|
|
||||||
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
|
// 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
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||||
import { setActiveAccount } from "../lib/api/core";
|
|
||||||
|
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
<ChannelsProvider>{children}</ChannelsProvider>
|
<ChannelsProvider>{children}</ChannelsProvider>
|
||||||
@@ -101,20 +100,17 @@ describe("ChannelsProvider — S8 shareable URL", () => {
|
|||||||
expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false);
|
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", () => {
|
it("a PARTIAL shared link 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
|
// Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and
|
||||||
// dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real
|
// persisting the seed overwrote the account's real stored sort + filter map.
|
||||||
// stored sort + filter map. The fix persists ONLY the URL-present dimensions.
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"siftlode.channelSort.1",
|
"siftlode.channelSort.1",
|
||||||
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
JSON.stringify({ key: "subscribers", dir: "desc" }),
|
||||||
);
|
);
|
||||||
localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" }));
|
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
|
window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL
|
||||||
|
|
||||||
const { result } = render();
|
const { result } = render();
|
||||||
act(() => setActiveAccount(1)); // App pins the account after /api/me
|
|
||||||
|
|
||||||
// The present dim (tab) is adopted from the link...
|
// The present dim (tab) is adopted from the link...
|
||||||
expect(result.current.view).toBe("hidden");
|
expect(result.current.view).toBe("hidden");
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getAccountRaw, setAccountRaw, LS } from "../lib/storage";
|
import { getAccountRaw, LS } from "../lib/storage";
|
||||||
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout";
|
||||||
import { hydrateSort, type SortState } from "../lib/columnSort";
|
import { hydrateSort, type SortState } from "../lib/columnSort";
|
||||||
import { useSortState } from "../lib/useSortState";
|
import { useSortState } from "../lib/useSortState";
|
||||||
import { useFilterState } from "../lib/useFilterState";
|
import { useFilterState } from "../lib/useFilterState";
|
||||||
import { useActiveAccountId } from "../lib/activeAccount";
|
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { rememberNavPosition } from "../lib/history";
|
import { rememberNavPosition } from "../lib/history";
|
||||||
import {
|
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
|
// 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
|
// 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).
|
// 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
|
// The base is this account's stored view (BootGate pins the account before this provider mounts), so
|
||||||
// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null),
|
// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest.
|
||||||
// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and
|
function readChannelsUrlSeed(): ChannelsUrlState | null {
|
||||||
// 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 {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (!hasChannelsParams(params)) return null;
|
if (!hasChannelsParams(params)) return null;
|
||||||
const state = channelsParamsToState(params, {
|
return channelsParamsToState(params, {
|
||||||
view: clampView(getAccountRaw(LS.channelsView)),
|
view: clampView(getAccountRaw(LS.channelsView)),
|
||||||
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
sort: hydrateSort(getAccountRaw(LS.channelSort), [], null),
|
||||||
filters: readStoredFilters(),
|
filters: readStoredFilters(),
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
state,
|
|
||||||
present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsState = {
|
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
|
// 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.
|
// chips, the Sync column funnel, and the card layout share one state; useFilterState persists it.
|
||||||
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip);
|
||||||
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so
|
// view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook).
|
||||||
// they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above
|
// Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history
|
||||||
// <App>, which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout
|
// logic (below) on top of the raw setter.
|
||||||
// heal them for display. setView layers the history logic (below) on top of the raw setter.
|
|
||||||
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
|
const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed");
|
||||||
const view = clampView(viewRaw);
|
const view = clampView(viewRaw);
|
||||||
const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table");
|
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).
|
// S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present).
|
||||||
const [urlSeed] = useState(readChannelsUrlSeed);
|
const [urlSeed] = useState(readChannelsUrlSeed);
|
||||||
const accountId = useActiveAccountId();
|
|
||||||
const seededRef = useRef(false);
|
const seededRef = useRef(false);
|
||||||
const persistedRef = useRef(false);
|
|
||||||
|
|
||||||
// Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters
|
// Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the
|
||||||
// (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate
|
// shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab
|
||||||
// (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view
|
// without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op.
|
||||||
// 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.
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!urlSeed || seededRef.current) return;
|
if (!urlSeed || seededRef.current) return;
|
||||||
seededRef.current = true;
|
seededRef.current = true;
|
||||||
// Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its
|
if (urlSeed.view !== view) setViewRaw(urlSeed.view);
|
||||||
// setter (which would markDirty and block the account-pin re-hydrate from loading the stored value)
|
setFilters(urlSeed.filters);
|
||||||
// — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters.
|
setSort(urlSeed.sort);
|
||||||
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);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// 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).
|
// 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`.
|
// 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
|
// 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.
|
// the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing.
|
||||||
if (v === viewRef.current) return;
|
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
|
// 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
|
// (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so
|
||||||
// one navigation never costs two Back presses.
|
// one navigation never costs two Back presses.
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
api,
|
|
||||||
getActiveAccount,
|
getActiveAccount,
|
||||||
coerceTagModes,
|
coerceTagModes,
|
||||||
coerceUntagged,
|
coerceUntagged,
|
||||||
DEFAULT_TAG_MODES,
|
DEFAULT_TAG_MODES,
|
||||||
type FeedFilters,
|
type FeedFilters,
|
||||||
|
type Me,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
@@ -53,9 +53,8 @@ const DEFAULT_FILTERS: FeedFilters = {
|
|||||||
|
|
||||||
const FILTERS_KEY = LS.filters;
|
const FILTERS_KEY = LS.filters;
|
||||||
|
|
||||||
// This account's own persisted filters (per-account keys — never another account's). When the
|
// This account's own persisted filters (per-account keys — never another account's). No account
|
||||||
// account isn't known yet (first load, before the tab is pinned), start from defaults; the post-login
|
// (signed out) → defaults.
|
||||||
// effect loads the real ones once the id arrives.
|
|
||||||
export function loadAccountFilters(id: number | null): FeedFilters {
|
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
|
// 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
|
// 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.
|
// 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);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
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 {
|
interface FeedFiltersActions {
|
||||||
@@ -108,18 +109,18 @@ export function useFeedFiltersActions(): FeedFiltersActions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
|
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
|
// 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.
|
// once at mount — such filters win over the account's stored view and are persisted to the account.
|
||||||
const [initialHadUrlFilters] = useState(() =>
|
const [initialHadUrlFilters] = useState(() =>
|
||||||
hasFilterParams(new URLSearchParams(window.location.search)),
|
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) => {
|
const setFilters = useCallback((next: FeedFilters) => {
|
||||||
setFiltersState(next);
|
setFiltersState(next);
|
||||||
// Persist under THIS tab's active account so filters don't bleed between accounts.
|
// 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();
|
const { page } = useNavigation();
|
||||||
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
|
useUrlMirror(filterParamUpdates(page === "feed" ? filters : null));
|
||||||
|
|
||||||
// Load THIS account's own filters when the account first resolves or changes (login / switch /
|
// A share link's filters (captured at mount) win over the account's stored view and are persisted
|
||||||
// add). Filters are per-account localStorage state, so another account's last view (incl. its
|
// to it once, so the view the link opened also survives a later plain reload.
|
||||||
// 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.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = meQuery.data?.id;
|
if (initialHadUrlFilters) setFilters(filters);
|
||||||
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);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.id]);
|
}, []);
|
||||||
|
|
||||||
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
|
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
|||||||
});
|
});
|
||||||
const configured = keyQ.data?.configured ?? false;
|
const configured = keyQ.data?.configured ?? false;
|
||||||
// Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts
|
// 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:
|
// have no password to check. Passive observer of the qk.me() query BootGate fetches (enabled:false),
|
||||||
// false — App does the fetching), so hasPassword stays correct even if me resolves after mount.
|
// so hasPassword follows the cache when a refetch changes it.
|
||||||
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||||
const hasPassword = meQ.data?.has_password ?? false;
|
const hasPassword = meQ.data?.has_password ?? false;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { urlWithParams, type Page } from "../lib/urlState";
|
import { urlWithParams, type Page } from "../lib/urlState";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { pageTitleKey } from "../lib/pageMeta";
|
||||||
import { setAccountRaw } from "../lib/storage";
|
import { setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
|
||||||
import { setNavigator } from "../lib/nav";
|
import { setNavigator } from "../lib/nav";
|
||||||
import {
|
import {
|
||||||
closeOverlaysThenNavigate,
|
closeOverlaysThenNavigate,
|
||||||
@@ -102,27 +101,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
const channelViewRef = useRef(channelView);
|
const channelViewRef = useRef(channelView);
|
||||||
channelViewRef.current = 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(
|
const setPage = useCallback(
|
||||||
(next: Page) =>
|
(next: Page) =>
|
||||||
// Never under an open overlay — see the interface doc. The refs are read when the push
|
// 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
|
// 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).
|
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||||
if (next === pageRef.current && !channelViewRef.current) return;
|
if (next === pageRef.current && !channelViewRef.current) return;
|
||||||
markDirty();
|
|
||||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||||
setPageState(next);
|
setPageState(next);
|
||||||
setAccountRaw(PAGE_KEY, next);
|
setAccountRaw(PAGE_KEY, next);
|
||||||
@@ -163,15 +140,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
);
|
);
|
||||||
notifyNav();
|
notifyNav();
|
||||||
}),
|
}),
|
||||||
[markDirty],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
|
// A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands
|
||||||
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
|
// under an open overlay: the player's channel link and the palette's channel rows call it from
|
||||||
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
|
// inside one.
|
||||||
//
|
|
||||||
// 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) => {
|
const openChannel = useCallback((id: string, name?: string) => {
|
||||||
closeOverlaysThenNavigate(() => {
|
closeOverlaysThenNavigate(() => {
|
||||||
const url = urlWithParams({ chan: id });
|
const url = urlWithParams({ chan: id });
|
||||||
@@ -197,7 +171,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const enterYtSearch = useCallback((q: string) => {
|
const enterYtSearch = useCallback((q: string) => {
|
||||||
// Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt.
|
|
||||||
setYtSearch(q);
|
setYtSearch(q);
|
||||||
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
|
// 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.
|
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
import { LS, setAccountRaw } from "../lib/storage";
|
import { LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { useNavigation } from "./NavigationProvider";
|
import { useNavigation } from "./NavigationProvider";
|
||||||
import { readSelectedId } from "./playlistsInit";
|
import { readSelectedId } from "./playlistsInit";
|
||||||
@@ -43,19 +42,11 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) {
|
|||||||
const [search, setSearchState] = useState("");
|
const [search, setSearchState] = useState("");
|
||||||
const [selectedId, setSelectedIdState] = useState<number | null>(readSelectedId);
|
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 setSearch = useCallback((q: string) => setSearchState(q), []);
|
||||||
const setSelectedId = useCallback(
|
const setSelectedId = useCallback((id: number | null) => {
|
||||||
(id: number | null) => {
|
setSelectedIdState(id);
|
||||||
markDirty();
|
if (id != null) setAccountRaw(LS.playlist, String(id));
|
||||||
setSelectedIdState(id);
|
}, []);
|
||||||
if (id != null) setAccountRaw(LS.playlist, String(id));
|
|
||||||
},
|
|
||||||
[markDirty],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere
|
// 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
|
// (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav
|
||||||
|
|||||||
@@ -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 { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
|
||||||
import { parseSortState, type SortState } from "../lib/columnSort";
|
import { parseSortState, type SortState } from "../lib/columnSort";
|
||||||
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
|
||||||
@@ -10,7 +18,6 @@ import {
|
|||||||
type PlexUrlState,
|
type PlexUrlState,
|
||||||
} from "../lib/plexUrlState";
|
} from "../lib/plexUrlState";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
||||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
|
||||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||||
import { useNavigation } from "./NavigationProvider";
|
import { useNavigation } from "./NavigationProvider";
|
||||||
|
|
||||||
@@ -20,10 +27,8 @@ import { useNavigation } from "./NavigationProvider";
|
|||||||
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
|
||||||
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
|
// 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
|
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
|
||||||
// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above
|
// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral
|
||||||
// <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
|
|
||||||
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
|
// 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
|
// 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.
|
// into (and is never greeted by a stale value from) the feed.
|
||||||
@@ -95,7 +100,7 @@ export function usePlexActions(): PlexActions {
|
|||||||
return useContext(ActionsContext);
|
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 {
|
function readPlexScope(): string {
|
||||||
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
const v = getAccountRaw(LS.plexLibrary) ?? "both";
|
||||||
return ["movie", "show", "both"].includes(v) ? v : "both";
|
return ["movie", "show", "both"].includes(v) ? v : "both";
|
||||||
@@ -144,45 +149,31 @@ function readPlexFilters(): PlexFilters {
|
|||||||
return EMPTY_PLEX_FILTERS;
|
return EMPTY_PLEX_FILTERS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
|
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
|
||||||
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
|
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
|
||||||
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
|
function readPlexUrlSeed(): PlexUrlState | null {
|
||||||
// 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 {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (!hasPlexParams(params)) return null;
|
if (!hasPlexParams(params)) return null;
|
||||||
const state = plexParamsToState(params, {
|
return plexParamsToState(params, {
|
||||||
scope: readPlexScope(),
|
scope: readPlexScope(),
|
||||||
sort: readPlexSort(),
|
sort: readPlexSort(),
|
||||||
show: readPlexShow(),
|
show: readPlexShow(),
|
||||||
filters: readPlexFilters(),
|
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 }) {
|
export function PlexProvider({ children }: { children: ReactNode }) {
|
||||||
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
|
// Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to
|
||||||
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
|
// the account (the effect further down), so the shared view also survives a later plain reload.
|
||||||
const [urlSeed] = useState(readPlexUrlSeed);
|
const [urlSeed] = useState(readPlexUrlSeed);
|
||||||
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
// 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".)
|
// (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 [scope, setScopeState] = useState<string>(() => urlSeed?.scope ?? readPlexScope());
|
||||||
const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
|
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
|
||||||
const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
|
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
|
||||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||||
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
|
||||||
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
|
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
|
||||||
);
|
);
|
||||||
const filters = useMemo<PlexFilters>(() => {
|
const filters = useMemo<PlexFilters>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -203,87 +194,42 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
// PlexBrowse consumes it into an info/show history subview then clears it.
|
// PlexBrowse consumes it into an info/show history subview then clears it.
|
||||||
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
|
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).
|
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
|
||||||
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
|
// later plain reload. The seed's base was the stored view, so a dimension the link omitted is written
|
||||||
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
|
// back unchanged. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
|
||||||
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
|
useEffect(() => {
|
||||||
const markDirty = useAccountRehydrate(() => {
|
if (!urlSeed) return;
|
||||||
if (urlSeed) {
|
setAccountRaw(LS.plexLibrary, urlSeed.scope);
|
||||||
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
|
setAccountRaw(LS.plexShow, urlSeed.show);
|
||||||
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
|
setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
|
||||||
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
|
setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
|
||||||
// default over the account's real stored value — re-read the stored value instead, exactly like a
|
}, [urlSeed]);
|
||||||
// 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());
|
|
||||||
});
|
|
||||||
|
|
||||||
const setScope = useCallback(
|
const setScope = useCallback((v: string) => {
|
||||||
(v: string) => {
|
setScopeState(v);
|
||||||
markDirty();
|
setAccountRaw(LS.plexLibrary, v);
|
||||||
setScopeState(v);
|
}, []);
|
||||||
setAccountRaw(LS.plexLibrary, v);
|
const setShow = useCallback((v: string) => {
|
||||||
},
|
setShowState(v);
|
||||||
[markDirty],
|
setAccountRaw(LS.plexShow, v);
|
||||||
);
|
}, []);
|
||||||
const setShow = useCallback(
|
const setSort = useCallback((v: SortState) => {
|
||||||
(v: string) => {
|
setSortState(v);
|
||||||
markDirty();
|
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
||||||
setShowState(v);
|
}, []);
|
||||||
setAccountRaw(LS.plexShow, v);
|
const setFilters = useCallback((f: PlexFilters) => {
|
||||||
},
|
const s = JSON.stringify(f);
|
||||||
[markDirty],
|
setFiltersRaw(s);
|
||||||
);
|
setAccountRaw(LS.plexFilters, s);
|
||||||
const setSort = useCallback(
|
}, []);
|
||||||
(v: SortState) => {
|
const setQ = useCallback((v: string) => {
|
||||||
markDirty();
|
setQState(v);
|
||||||
setSortState(v);
|
setAccountRaw(LS.plexQ, v);
|
||||||
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
}, []);
|
||||||
},
|
const setField = useCallback((v: PlexSearchField) => {
|
||||||
[markDirty],
|
setFieldState(v);
|
||||||
);
|
setAccountRaw(LS.plexField, v);
|
||||||
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 state = useMemo<PlexState>(
|
const state = useMemo<PlexState>(
|
||||||
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
|
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { api } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
applyTheme,
|
applyTheme,
|
||||||
DEFAULT_THEME,
|
DEFAULT_THEME,
|
||||||
@@ -53,35 +54,47 @@ export function usePrefs(): PrefsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PrefsProvider({ children }: { children: ReactNode }) {
|
export function PrefsProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
const qc = useQueryClient();
|
||||||
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
// The account's prefs, seeded straight from the `me` cache — BootGate mounts this only once /api/me
|
||||||
const [hints, setHints] = useState(() => hintsEnabled());
|
// has answered, so a theme changed on another device is right on the first paint instead of
|
||||||
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
// switching after it. This browser's copy fills any key the server has never stored.
|
||||||
// The last server-known prefs. Written on adopt + after each save so the auto-save effect can
|
const [seed] = useState<EditablePrefs>(() => {
|
||||||
// compare against it and avoid echoing a server-adopted value straight back. A ref (drives no UI).
|
const prefs = qc.getQueryData<Me | null>(qk.me())?.preferences;
|
||||||
const baselineRef = useRef<EditablePrefs>({
|
return {
|
||||||
theme: loadLocalTheme(),
|
theme: prefs?.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
performanceMode:
|
||||||
hints: hintsEnabled(),
|
typeof prefs?.performanceMode === "boolean"
|
||||||
notifications: getNotifSettings(),
|
? 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 [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||||
const saveTimer = 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
|
// 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.
|
// stores too); persistence to the server is the auto-save effect below. The look is applied in
|
||||||
useEffect(() => applyTheme(theme), [theme]);
|
// LAYOUT effects, before the browser paints, so the app's first frame is already in the account's
|
||||||
useEffect(() => {
|
// 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" : "";
|
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||||
setAccountRaw(PERF_KEY, perf ? "1" : "0");
|
setAccountRaw(PERF_KEY, perf ? "1" : "0");
|
||||||
}, [perf]);
|
}, [perf]);
|
||||||
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
|
// 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
|
// 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.
|
// theme-appropriate image on <body> and switches the glass to translucent.
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const on = theme.bgImage && !perf;
|
const on = theme.bgImage && !perf;
|
||||||
document.documentElement.dataset.backdrop = on ? "on" : "off";
|
document.documentElement.dataset.backdrop = on ? "on" : "off";
|
||||||
}, [theme.bgImage, perf]);
|
}, [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.
|
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
|
||||||
const setTheme = useCallback((next: ThemePrefs) => {
|
const setTheme = useCallback((next: ThemePrefs) => {
|
||||||
setThemeState(next);
|
setThemeState(next);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ vi.mock("./PlexProvider", () => ({
|
|||||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||||
|
|
||||||
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
|
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 { LS, accountKey } from "../lib/storage";
|
||||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||||
import { qk } from "../lib/queryKeys";
|
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" });
|
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 () => {
|
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
||||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
|||||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../lib/actionErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { useActiveAccountId } from "../lib/activeAccount";
|
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { useNavigation } from "./NavigationProvider";
|
import { useNavigation } from "./NavigationProvider";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
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
|
// 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
|
// 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
|
// 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.
|
// 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
|
||||||
// WAIT FOR THE ACCOUNT PIN. This provider mounts above <App>, which pins the tab-account only after
|
// before this provider mounts.
|
||||||
// /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();
|
|
||||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const data = viewsQuery.data;
|
const data = viewsQuery.data;
|
||||||
if (!data || accountId == null || seededRef.current.has(source)) return;
|
if (!data || seededRef.current.has(source)) return;
|
||||||
seededRef.current.add(source);
|
seededRef.current.add(source);
|
||||||
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
||||||
if (!def) return;
|
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
|
// 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.
|
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [viewsQuery.data, source, accountId]);
|
}, [viewsQuery.data, source]);
|
||||||
|
|
||||||
const currentKey = cfg.keyOf(cfg.filters);
|
const currentKey = cfg.keyOf(cfg.filters);
|
||||||
const activeView = useMemo(
|
const activeView = useMemo(
|
||||||
|
|||||||
@@ -131,10 +131,9 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
|||||||
// Controlled by App's prefs draft: each change applies locally for preview but is only
|
// 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).
|
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
|
||||||
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
|
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
|
||||||
// The landing page (E24). NOT a server preference like the rest of this tab: `loadInitialPage`
|
// The landing page (E24). Per-account localStorage, not a server preference like the rest of this
|
||||||
// has to answer before /api/me lands, so the value must be readable synchronously from
|
// tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored
|
||||||
// localStorage — a landing module decided after the first paint would be a visible jump. The two
|
// values come from navigationInit, which is what reads them back at boot.
|
||||||
// stored values come from navigationInit, which is what reads them back at boot.
|
|
||||||
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
|
const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (BootGate owns
|
||||||
// fetching query), so it re-runs the auto-open check when the account resolves or its grants change.
|
// the fetching query), so it re-runs the auto-open check when the account's grants change.
|
||||||
|
|
||||||
interface WizardActions {
|
interface WizardActions {
|
||||||
openWizard: () => void;
|
openWizard: () => void;
|
||||||
@@ -43,7 +43,7 @@ export function useWizard(): WizardActions {
|
|||||||
|
|
||||||
export function WizardProvider({ children }: { children: ReactNode }) {
|
export function WizardProvider({ children }: { children: ReactNode }) {
|
||||||
const [open, setOpen] = useState(false);
|
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 { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||||
|
|
||||||
const openWizard = useCallback(() => setOpen(true), []);
|
const openWizard = useCallback(() => setOpen(true), []);
|
||||||
|
|||||||
@@ -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 { Page } from "../lib/urlState";
|
||||||
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
||||||
import { useMe } from "../lib/useMe";
|
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).
|
// 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
|
// 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>.
|
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>. Each
|
||||||
const Home = lazy(() => import("./Home"));
|
// one is preloadable: App fetches the chunks of every page the account can reach once the first page
|
||||||
const Feed = lazy(() => import("./Feed"));
|
// is on screen (preloadPages below), so switching modules never shows the chunk fallback.
|
||||||
const Channels = lazy(() => import("./Channels"));
|
const Home = lazyWithPreload(() => import("./Home"));
|
||||||
const Playlists = lazy(() => import("./Playlists"));
|
const Feed = lazyWithPreload(() => import("./Feed"));
|
||||||
const PlexBrowse = lazy(() => import("./PlexBrowse"));
|
const Channels = lazyWithPreload(() => import("./Channels"));
|
||||||
const PlexSidebar = lazy(() => import("./PlexSidebar"));
|
const Playlists = lazyWithPreload(() => import("./Playlists"));
|
||||||
const NotificationsPanel = lazy(() => import("./NotificationsPanel"));
|
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
|
||||||
const Messages = lazy(() => import("./Messages"));
|
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
|
||||||
const DownloadCenter = lazy(() => import("./DownloadCenter"));
|
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
|
||||||
const Stats = lazy(() => import("./Stats"));
|
const Messages = lazyWithPreload(() => import("./Messages"));
|
||||||
const Scheduler = lazy(() => import("./Scheduler"));
|
const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter"));
|
||||||
const ConfigPanel = lazy(() => import("./ConfigPanel"));
|
const Stats = lazyWithPreload(() => import("./Stats"));
|
||||||
const AdminUsers = lazy(() => import("./AdminUsers"));
|
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
||||||
const AuditLog = lazy(() => import("./AuditLog"));
|
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
||||||
const SettingsPanel = lazy(() => import("./SettingsPanel"));
|
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
|
// 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.)
|
// 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 = {
|
export type PageDef = {
|
||||||
// Prop-free by construction: reads `me` + module state from hooks/providers.
|
// Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so
|
||||||
Component: ComponentType;
|
// 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
|
// 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.
|
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
|
||||||
fadeTop?: boolean;
|
fadeTop?: boolean;
|
||||||
@@ -94,12 +98,13 @@ export type PageDef = {
|
|||||||
// page id → the component App renders in the content column (+ its shell chrome config).
|
// page id → the component App renders in the content column (+ its shell chrome config).
|
||||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||||
home: { Component: Home, maxWidth: "80rem" },
|
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" },
|
channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" },
|
||||||
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
|
playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" },
|
||||||
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
||||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||||
messages: { Component: MessagesPage },
|
messages: { Component: Object.assign(MessagesPage, { preload: Messages.preload }) },
|
||||||
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
||||||
stats: { Component: Stats },
|
stats: { Component: Stats },
|
||||||
scheduler: { Component: Scheduler },
|
scheduler: { Component: Scheduler },
|
||||||
@@ -134,3 +139,13 @@ export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
|
|||||||
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
||||||
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
|
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
|
// 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
|
// 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.
|
// 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 }) {
|
export function LoadingState({ label, className = "" }: { label?: ReactNode; className?: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
|
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"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
// 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 {
|
function detectInitialLang(): LangCode {
|
||||||
const stored = localStorage.getItem(LANG_KEY);
|
const stored = localStorage.getItem(LANG_KEY);
|
||||||
if (isSupported(stored)) return stored;
|
if (isSupported(stored)) return stored;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useSyncExternalStore } from "react";
|
// 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
|
||||||
// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts), plus its reactive
|
// on it, without a circular import — the account id is a lower-level primitive than the storage
|
||||||
// wrapper. Its OWN module (imports nothing internal) so it can be shared by both `storage.ts` (which
|
// helpers that consume it.
|
||||||
// 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.
|
// 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.
|
// ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free.
|
||||||
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
|
export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
|
||||||
@@ -29,22 +30,3 @@ export function withActiveAccount(path: string): string {
|
|||||||
if (id == null) return path;
|
if (id == null) return path;
|
||||||
return `${path}${path.includes("?") ? "&" : "?"}account=${id}`;
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" }
|
|||||||
vi.mock("../activeAccount", () => ({
|
vi.mock("../activeAccount", () => ({
|
||||||
activeAccountId: () => null,
|
activeAccountId: () => null,
|
||||||
ACTIVE_ACCOUNT_KEY: "k",
|
ACTIVE_ACCOUNT_KEY: "k",
|
||||||
notifyActiveAccountChange: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getActiveToasts, notify, remove } from "../notifications";
|
|||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
import { formatBytes } from "../format";
|
import { formatBytes } from "../format";
|
||||||
import { reportError } from "../errorDialog";
|
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").
|
/** 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
|
* 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 {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
// Let per-account UI state re-hydrate now the account is known (see notifyActiveAccountChange).
|
|
||||||
notifyActiveAccountChange();
|
|
||||||
}
|
}
|
||||||
export function clearActiveAccount(): void {
|
export function clearActiveAccount(): void {
|
||||||
try {
|
try {
|
||||||
@@ -277,7 +275,6 @@ export function clearActiveAccount(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
notifyActiveAccountChange();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function req<T = unknown>(
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { activeAccountId } from "./activeAccount";
|
import { activeAccountId } from "./activeAccount";
|
||||||
import { useAccountRehydrate } from "./useAccountRehydrate";
|
|
||||||
|
|
||||||
// Central registry of every localStorage key the app uses. One place to see them all, rename
|
// 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
|
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
|
||||||
@@ -16,15 +15,13 @@ export const LS = {
|
|||||||
filters: "siftlode.filters",
|
filters: "siftlode.filters",
|
||||||
page: "siftlode.page",
|
page: "siftlode.page",
|
||||||
// E24: start every session on the Home surface rather than on the module you happened to leave
|
// 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
|
// open. Per-account, "0" = opt out and go back to remembering the last module. Read by
|
||||||
// localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands —
|
// `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's
|
||||||
// a landing page decided after the first paint would be a visible jump.
|
// account is pinned — so the landing is decided right before the first paint, never corrected after.
|
||||||
startOnHome: "siftlode.startOnHome",
|
startOnHome: "siftlode.startOnHome",
|
||||||
perfMode: "siftlode.perfMode",
|
perfMode: "siftlode.perfMode",
|
||||||
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
|
// The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
|
||||||
// established tab paints your view straight away. A brand-new tab still starts on the default
|
// from only when the server has never stored the key.
|
||||||
// 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.
|
|
||||||
feedView: "siftlode.feedView",
|
feedView: "siftlode.feedView",
|
||||||
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
|
// 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
|
// 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;
|
} as const;
|
||||||
|
|
||||||
// --- per-account scoping -----------------------------------------------------------------
|
// --- 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.
|
// 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
|
/** 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
|
/** 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
|
* 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
|
* 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
|
* can be used before an async-loaded option list is known, keeping hook order stable. Read once, at
|
||||||
* usually mount only once signed in (account known), but re-read on the account-pin transition too so
|
* mount: BootGate mounts every consumer after the account is pinned. */
|
||||||
* one that mounts before the pin (a fresh tab's first paint) still restores. */
|
|
||||||
export function useAccountPersistedState(
|
export function useAccountPersistedState(
|
||||||
base: string,
|
base: string,
|
||||||
fallback = "",
|
fallback = "",
|
||||||
): [string, (value: string) => void] {
|
): [string, (value: string) => void] {
|
||||||
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
const [value, setValue] = useState<string>(() => getAccountRaw(base) ?? fallback);
|
||||||
const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
|
// Stable setter (base is stable) so a provider can build a stable action on top of it — e.g.
|
||||||
// Stable setter (base + markDirty are both stable) so a provider can build a stable action on top
|
// ChannelsProvider's setView, which must not change identity on every tab switch.
|
||||||
// of it — e.g. ChannelsProvider's setView, which must not change identity on every tab switch.
|
|
||||||
const set = useCallback(
|
const set = useCallback(
|
||||||
(next: string) => {
|
(next: string) => {
|
||||||
markDirty();
|
|
||||||
setValue(next);
|
setValue(next);
|
||||||
setAccountRaw(base, next);
|
setAccountRaw(base, next);
|
||||||
},
|
},
|
||||||
[base, markDirty],
|
[base],
|
||||||
);
|
);
|
||||||
return [value, set];
|
return [value, set];
|
||||||
}
|
}
|
||||||
@@ -184,17 +178,15 @@ export function useAccountPersistedObject<T extends object>(
|
|||||||
defaults: T,
|
defaults: T,
|
||||||
): [T, (patch: Partial<T>) => void] {
|
): [T, (patch: Partial<T>) => void] {
|
||||||
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
const [value, setValue] = useState<T>(() => readAccountMerged(base, defaults));
|
||||||
const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
|
|
||||||
const patch = useCallback(
|
const patch = useCallback(
|
||||||
(p: Partial<T>) => {
|
(p: Partial<T>) => {
|
||||||
markDirty();
|
|
||||||
setValue((prev) => {
|
setValue((prev) => {
|
||||||
const next = { ...prev, ...p };
|
const next = { ...prev, ...p };
|
||||||
writeAccount(base, next);
|
writeAccount(base, next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[base, markDirty],
|
[base],
|
||||||
);
|
);
|
||||||
return [value, patch];
|
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
@@ -6,26 +6,19 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { renderHook, act } from "@testing-library/react";
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
|
||||||
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
|
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
|
||||||
// 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 },
|
|
||||||
}));
|
|
||||||
vi.mock("./storage", () => ({
|
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) => {
|
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";
|
import { useFilterState } from "./useFilterState";
|
||||||
|
|
||||||
describe("useFilterState", () => {
|
describe("useFilterState", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store.clear();
|
store.clear();
|
||||||
ctl.account = 1;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates to an empty map when storage is empty", () => {
|
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.
|
// A seed that returns null falls back to an empty map.
|
||||||
expect(renderHook(() => useFilterState("k3", () => null)).result.current[0]).toEqual({});
|
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"
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { getAccountRaw, setAccountRaw } from "./storage";
|
import { getAccountRaw, setAccountRaw } from "./storage";
|
||||||
import { useAccountRehydrate } from "./useAccountRehydrate";
|
|
||||||
import type { FilterMap } from "./tableColumns";
|
import type { FilterMap } from "./tableColumns";
|
||||||
|
|
||||||
// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the
|
// 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.
|
// flash); it stays idempotent because the seed persists as soon as the user touches the filters.
|
||||||
migrate?: () => FilterMap | null,
|
migrate?: () => FilterMap | null,
|
||||||
): [FilterMap, (next: FilterMap) => void] {
|
): [FilterMap, (next: FilterMap) => void] {
|
||||||
// Read the account's stored map, or the migrate seed when its key is still empty. Shared by the
|
// The account's stored map, or the migrate seed when its key is still empty.
|
||||||
// initial mount AND the account-pin re-hydrate, so first-load migration runs on whichever fires with
|
const [filters, setFilters] = useState<FilterMap>(() => {
|
||||||
// the account known (on a fresh tab the account is null at mount → the re-hydrate does the real read).
|
|
||||||
const read = useCallback((): FilterMap => {
|
|
||||||
const raw = getAccountRaw(storageBase);
|
const raw = getAccountRaw(storageBase);
|
||||||
return raw ? hydrate(raw) : (migrate?.() ?? {});
|
return raw ? hydrate(raw) : (migrate?.() ?? {});
|
||||||
}, [storageBase, migrate]);
|
});
|
||||||
const [filters, setFilters] = useState<FilterMap>(read);
|
|
||||||
const markDirty = useAccountRehydrate(() => setFilters(read()));
|
|
||||||
const set = useCallback(
|
const set = useCallback(
|
||||||
(next: FilterMap) => {
|
(next: FilterMap) => {
|
||||||
markDirty();
|
|
||||||
setFilters(next);
|
setFilters(next);
|
||||||
setAccountRaw(storageBase, JSON.stringify(next));
|
setAccountRaw(storageBase, JSON.stringify(next));
|
||||||
},
|
},
|
||||||
[storageBase, markDirty],
|
[storageBase],
|
||||||
);
|
);
|
||||||
return [filters, set];
|
return [filters, set];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { qk } from "./queryKeys";
|
import { qk } from "./queryKeys";
|
||||||
import { api, type Me } from "./api";
|
import { api, type Me } from "./api";
|
||||||
|
|
||||||
// The signed-in account, read from the qk.me() query cache. App owns the FETCHING query (gated on
|
// The signed-in account, read from the qk.me() query cache. BootGate owns the FETCHING query (gated on
|
||||||
// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the
|
// 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
|
// 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
|
// 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
|
// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
//
|
//
|
||||||
// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache /
|
// 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),
|
// 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.
|
// 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 { 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createElement, type ReactNode } from "react";
|
import { createElement, type ReactNode } from "react";
|
||||||
|
|
||||||
@@ -125,19 +125,6 @@ describe("usePref", () => {
|
|||||||
expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" });
|
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", () => {
|
describe("debounceMs", () => {
|
||||||
beforeEach(() => vi.useFakeTimers());
|
beforeEach(() => vi.useFakeTimers());
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|||||||
+12
-34
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { qk } from "./queryKeys";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import type { Me } from "./api/account";
|
import type { Me } from "./api/account";
|
||||||
@@ -14,24 +14,19 @@ const reportPrefSaveFailure = (err: unknown) =>
|
|||||||
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
|
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
|
||||||
|
|
||||||
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
|
// 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
|
// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
|
||||||
// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its
|
// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and
|
||||||
// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache,
|
// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new
|
||||||
// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds
|
// server-synced pref is a one-liner.
|
||||||
// all three into one hook so a new server-synced pref is a one-liner.
|
|
||||||
//
|
//
|
||||||
// Two mirror strategies, unified here (pick via `localKey`):
|
// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the
|
||||||
// • Providers mounted ABOVE <App> (feed view, panel layouts, rail-collapse flags) render before
|
// app tree only once /api/me has answered, keyed on the account, so there is nothing to adopt after
|
||||||
// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on
|
// mount. The source of truth is the server; the `me` cache is kept in sync optimistically so other
|
||||||
// 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
|
|
||||||
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
// consumers reading the same key (e.g. a second player instance) see the change at once.
|
||||||
|
|
||||||
export interface UsePrefOptions<T> {
|
export interface UsePrefOptions<T> {
|
||||||
/** Also mirror to per-account localStorage under this base key, and seed synchronously from it on
|
/** Also mirror to per-account localStorage under this base key; that copy seeds the value when the
|
||||||
* first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
|
* server payload lacks the key. */
|
||||||
localKey?: string;
|
localKey?: string;
|
||||||
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
|
/** 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. */
|
* 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
|
/** A per-account preference persisted to the server (and optionally mirrored to localStorage).
|
||||||
* automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */
|
* Returns `[value, setValue]`; the setter is stable. */
|
||||||
export function usePref<T>(
|
export function usePref<T>(
|
||||||
serverKey: string,
|
serverKey: string,
|
||||||
defaultValue: T,
|
defaultValue: T,
|
||||||
@@ -79,8 +74,6 @@ export function usePref<T>(
|
|||||||
): [T, (next: T) => void] {
|
): [T, (next: T) => void] {
|
||||||
const { localKey, debounceMs, parse } = options;
|
const { localKey, debounceMs, parse } = options;
|
||||||
const qc = useQueryClient();
|
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
|
// 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.
|
// 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],
|
[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];
|
return [value, set];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,19 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { renderHook, act } from "@testing-library/react";
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
|
||||||
// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
|
const { store } = vi.hoisted(() => ({ store: new Map<string, string>() }));
|
||||||
// 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 },
|
|
||||||
}));
|
|
||||||
vi.mock("./storage", () => ({
|
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) => {
|
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";
|
import { useSortState } from "./useSortState";
|
||||||
|
|
||||||
describe("useSortState", () => {
|
describe("useSortState", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store.clear();
|
store.clear();
|
||||||
ctl.account = 1;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates to the fallback when storage is empty", () => {
|
it("hydrates to the fallback when storage is empty", () => {
|
||||||
@@ -62,29 +55,4 @@ describe("useSortState", () => {
|
|||||||
const { result } = renderHook(() => useSortState("k"));
|
const { result } = renderHook(() => useSortState("k"));
|
||||||
expect(result.current[0]).toEqual({ key: "anything", dir: "desc" });
|
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,6 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { getAccountRaw, setAccountRaw } from "./storage";
|
import { getAccountRaw, setAccountRaw } from "./storage";
|
||||||
import { useAccountRehydrate } from "./useAccountRehydrate";
|
|
||||||
import { hydrateSort, type SortOption, type SortState } from "./columnSort";
|
import { hydrateSort, type SortOption, type SortState } from "./columnSort";
|
||||||
|
|
||||||
// The per-account persisted sort state, hydrated + healed against the current options — the plumbing
|
// 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>(() =>
|
const [sort, setSort] = useState<SortState>(() =>
|
||||||
hydrateSort(getAccountRaw(storageBase), options, fallback),
|
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(
|
const set = useCallback(
|
||||||
(next: SortState) => {
|
(next: SortState) => {
|
||||||
markDirty();
|
|
||||||
setSort(next);
|
setSort(next);
|
||||||
setAccountRaw(storageBase, JSON.stringify(next));
|
setAccountRaw(storageBase, JSON.stringify(next));
|
||||||
},
|
},
|
||||||
[storageBase, markDirty],
|
[storageBase],
|
||||||
);
|
);
|
||||||
return [sort, set];
|
return [sort, set];
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-23
@@ -5,6 +5,7 @@ import { createQueryClient } from "./lib/queryClient";
|
|||||||
import { FloatingTree } from "@floating-ui/react";
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||||
|
import BootGate, { BootScreen } from "./components/BootGate";
|
||||||
import { NavigationProvider } from "./components/NavigationProvider";
|
import { NavigationProvider } from "./components/NavigationProvider";
|
||||||
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
||||||
import { SavedViewsProvider } from "./components/SavedViewsProvider";
|
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
|
// 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
|
// 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.
|
// 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 PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
|
||||||
const Terms = lazy(() => import("./components/legal/Terms"));
|
const Terms = lazy(() => import("./components/legal/Terms"));
|
||||||
const WatchPage = lazy(() => import("./components/WatchPage"));
|
const WatchPage = lazy(() => import("./components/WatchPage"));
|
||||||
@@ -39,6 +41,10 @@ de.theme ||= "dark";
|
|||||||
de.scheme ||= "midnight";
|
de.scheme ||= "midnight";
|
||||||
|
|
||||||
const path = window.location.pathname;
|
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 =
|
const root =
|
||||||
path === "/privacy" ? (
|
path === "/privacy" ? (
|
||||||
<PrivacyPolicy />
|
<PrivacyPolicy />
|
||||||
@@ -56,29 +62,37 @@ const root =
|
|||||||
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
||||||
<FloatingTree>
|
<FloatingTree>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<NavigationProvider>
|
{/* Nothing below renders until the tab knows which account it is: every provider
|
||||||
<FeedFiltersProvider>
|
seeds per-account state in its initializer (see BootGate). */}
|
||||||
<PlaylistsProvider>
|
<BootGate>
|
||||||
<PlexProvider>
|
<NavigationProvider>
|
||||||
<PlexViewBridge>
|
<FeedFiltersProvider>
|
||||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
<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. */}
|
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
||||||
<SavedViewsProvider>
|
<SavedViewsProvider>
|
||||||
<ChannelsProvider>
|
<ChannelsProvider>
|
||||||
<FeedViewProvider>
|
<FeedViewProvider>
|
||||||
<PrefsProvider>
|
<PrefsProvider>
|
||||||
<WizardProvider>
|
<WizardProvider>
|
||||||
<App />
|
{/* The same quiet screen as the gate's, so a chunk that is still
|
||||||
</WizardProvider>
|
loading when the gate opens continues it instead of blanking. */}
|
||||||
</PrefsProvider>
|
<Suspense fallback={<BootScreen />}>
|
||||||
</FeedViewProvider>
|
<App />
|
||||||
</ChannelsProvider>
|
</Suspense>
|
||||||
</SavedViewsProvider>
|
</WizardProvider>
|
||||||
</PlexViewBridge>
|
</PrefsProvider>
|
||||||
</PlexProvider>
|
</FeedViewProvider>
|
||||||
</PlaylistsProvider>
|
</ChannelsProvider>
|
||||||
</FeedFiltersProvider>
|
</SavedViewsProvider>
|
||||||
</NavigationProvider>
|
</PlexViewBridge>
|
||||||
|
</PlexProvider>
|
||||||
|
</PlaylistsProvider>
|
||||||
|
</FeedFiltersProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
</BootGate>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
</FloatingTree>
|
</FloatingTree>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
Reference in New Issue
Block a user