diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cf0401f5..4570486f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,9 +2,9 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { qk } from "./lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api"; +import { api, setUnauthorizedHandler, type Me } from "./lib/api"; import { notifyActionError } from "./lib/actionErrors"; -import { setLanguage, isSupported, type LangCode } from "./i18n"; +import { setLanguage, type LangCode } from "./i18n"; import { clearParams } from "./lib/urlState"; import type { Page } from "./lib/urlState"; import { @@ -24,7 +24,9 @@ import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersP import { useChannelLayout } from "./components/ChannelsProvider"; import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider"; import { moduleDef, moduleOrder } from "./lib/modules"; -import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry"; +import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry"; +import { lazyWithPreload } from "./lib/lazyWithPreload"; +import { LoadingState } from "./components/ui/QueryState"; // Persistent shell — always mounted, so eagerly imported. import Welcome from "./components/Welcome"; import SetupWizard from "./components/SetupWizard"; @@ -38,7 +40,6 @@ import Toaster from "./components/ui/Toaster"; import KeyboardHelp from "./components/ui/KeyboardHelp"; import AppCommandPalette from "./components/AppCommandPalette"; import { useGlobalKeymap } from "./lib/keymap"; -import { StateMessage } from "./components/ui/QueryState"; import ErrorDialog from "./components/ErrorDialog"; import VersionBanner from "./components/VersionBanner"; import DemoBanner from "./components/DemoBanner"; @@ -52,7 +53,7 @@ import { FRONTEND_VERSION } from "./lib/version"; // The page modules (and their side rails) are code-split per route via the module registry // (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the // two modals, which sit outside the registry-driven content column. -const ChannelPage = lazy(() => import("./components/ChannelPage")); +const ChannelPage = lazyWithPreload(() => import("./components/ChannelPage")); const About = lazy(() => import("./components/About")); const ReleaseNotes = lazy(() => import("./components/ReleaseNotes")); @@ -79,13 +80,20 @@ export default function App() { // by the feed page and the channel page. App reads it only to hand to /. const view = useFeedView(); const { setView } = useFeedViewActions(); + const queryClient = useQueryClient(); // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards), - // persisted per-account. One record so feed/plex/playlists share the same mechanism. - const [panelLayouts, setPanelLayouts] = useState>(() => ({ - feed: loadLayout("feed"), - plex: loadLayout("plex"), - playlists: loadLayout("playlists"), - })); + // persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from + // the account's server preferences — BootGate mounts App only once `me` is cached — so a layout + // changed on another device is right on the first paint; the local copy covers a key the server + // has never stored. + const [panelLayouts, setPanelLayouts] = useState>(() => { + const prefs = queryClient.getQueryData(qk.me())?.preferences; + const seed = (p: PanelId) => { + const raw = prefs?.[PREF_KEY[p]]; + return raw ? normalizeLayout(p, raw) : loadLayout(p); + }; + return { feed: seed("feed"), plex: seed("plex"), playlists: seed("playlists") }; + }); const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); @@ -119,26 +127,15 @@ export default function App() { // (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and // NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.) - const queryClient = useQueryClient(); - // First-run install gate: a fresh instance reports configured=false and runs in setup mode. - // We check this before anything else and render the wizard; `me` is held back until we know the - // instance is set up (otherwise it would 503 against the setup-mode lock). + // Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls + // and waits for: App mounts only once both have answered, so their caches are always populated here. const setupQuery = useQuery({ queryKey: qk.setupStatus(), queryFn: api.setupStatus, - staleTime: Infinity, + enabled: false, }); const needsSetup = setupQuery.data?.configured === false; - // Poll the session so an account suspended/deleted by an admin is noticed even with no - // interaction (option 1): the refetch 401s → the 401 handler below drops to the login page. - // Only poll while signed in (data present) — polling on the public login page is pointless and - // its periodic refetch caused a visible flicker there. - const meQuery = useQuery({ - queryKey: qk.me(), - queryFn: api.me, - enabled: setupQuery.isSuccess && !needsSetup, - refetchInterval: (query) => (query.state.data ? 60_000 : false), - }); + const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); // The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to // Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted @@ -155,14 +152,25 @@ export default function App() { meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [], ); - // Pin this tab to whatever account it first loaded as. Without this, a tab that never picked - // an account (it was riding the session's default) would silently swap identity when ANOTHER - // tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this - // tab always sends its own account header, so cross-tab changes can't reach it. An explicit - // switch on THIS tab sets the override itself (so this no-ops then). + // Fetch the code of every module this account can open — and the channel page — once the first page + // is on screen, so a module's first visit renders at once instead of flashing the chunk fallback. + // Idle-scheduled so it never competes with the first page's own requests (Safari has no + // requestIdleCallback, hence the timer). useEffect(() => { - if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id); - }, [meQuery.data?.id]); + const me = meQuery.data; + if (!me) return; + const run = () => { + preloadPages([...moduleOrder(me), "settings"]); + ChannelPage.preload().catch(() => {}); + }; + const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined; + if (idle) { + const id = idle(run, { timeout: 3000 }); + return () => window.cancelIdleCallback(id); + } + const id = window.setTimeout(run, 1000); + return () => window.clearTimeout(id); + }, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps // Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the // toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves @@ -238,28 +246,6 @@ export default function App() { clearParams("admin"); }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps - // On login, adopt the server-stored SHELL preferences (panel layouts + language). The rail-collapse - // flags (filterCollapsed / playlistsCollapsed) are adopted by their own usePref hooks, the - // Settings-page prefs (theme/perf/hints/notifications) by PrefsProvider, and the feed view by - // FeedViewProvider — each via its own passive qk.me() observer. - useEffect(() => { - const prefs = meQuery.data?.preferences; - if (!prefs) return; - (["feed", "plex", "playlists"] as PanelId[]).forEach((p) => { - const raw = prefs[PREF_KEY[p]]; - if (raw) { - const l = normalizeLayout(p, raw); - setPanelLayouts((prev) => ({ ...prev, [p]: l })); - saveLayoutLocal(p, l); - } - }); - const lang = prefs.language; - if (typeof lang === "string" && isSupported(lang)) setLanguage(lang); - // (Per-account filters — incl. the demo account's whole-library default — are loaded by the - // dedicated effect above, which also covers accounts that have no stored preferences.) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [meQuery.data?.id]); - // Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the // pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a // fresh load (which is why the notice used to linger until F5 after an approval). @@ -304,32 +290,14 @@ export default function App() { } // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard. - if (setupQuery.isLoading) - return ( -
{t("common.loading")}
- ); if (needsSetup) return ; - - if (meQuery.isLoading) - return ( -
{t("common.loading")}
- ); - // /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 ( -
- meQuery.refetch()}> - {t("common.somethingWrong")} - -
- ); + // /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed + // probe — so no data here means "not signed in" → the public landing. if (!meQuery.data) return ; - // Shown briefly while a lazy page/module chunk loads (first visit to that page only). - const pageFallback = ( -
{t("common.loading")}
- ); + // Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the + // preload failed), and like every loading state it stays invisible for a load that is quick anyway. + const pageFallback = ; // 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 diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx new file mode 100644 index 00000000..59e608cb --- /dev/null +++ b/frontend/src/components/BootGate.dom.test.tsx @@ -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
{page}
; +} + +function mount() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + + + , + ); +} +const account = (over: Partial = {}) => ({ 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(() => {})); + 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(); + }); +}); diff --git a/frontend/src/components/BootGate.tsx b/frontend/src/components/BootGate.tsx new file mode 100644 index 00000000..666dafdf --- /dev/null +++ b/frontend/src/components/BootGate.tsx @@ -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 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 ( +
+ 0 ? { animation: `fadeIn 0.2s ease ${wait}ms both` } : undefined}> + {t("common.loading")} + +
+ ); +} +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(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 ( +
+ meQuery.refetch()}> + {t("common.somethingWrong")} + +
+ ); + if (identity === undefined || readyFor !== identity) return ; + return {children}; +} diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx index acca1831..829ad37d 100644 --- a/frontend/src/components/ChannelsProvider.dom.test.tsx +++ b/frontend/src/components/ChannelsProvider.dom.test.tsx @@ -17,7 +17,6 @@ vi.mock("./NavigationProvider", () => ({ })); import { ChannelsProvider, useChannels, useChannelsActions } from "./ChannelsProvider"; -import { setActiveAccount } from "../lib/api/core"; const wrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -101,20 +100,17 @@ describe("ChannelsProvider — S8 shareable URL", () => { expect(new URLSearchParams(window.location.search).has("ctab")).toBe(false); }); - it("a PARTIAL shared link (fresh tab) does not clobber the account's stored sort/filters", () => { - // Regression: on a fresh tab the account pins AFTER mount, so the seed's base for the OMITTED - // dimensions was read as defaults. Persisting the whole seed used to overwrite the account's real - // stored sort + filter map. The fix persists ONLY the URL-present dimensions. + it("a PARTIAL shared link does not clobber the account's stored sort/filters", () => { + // Regression: the seed's base for the OMITTED dimensions used to be read as defaults, and + // persisting the seed overwrote the account's real stored sort + filter map. localStorage.setItem( "siftlode.channelSort.1", JSON.stringify({ key: "subscribers", dir: "desc" }), ); localStorage.setItem("siftlode.channelsFilterMap.1", JSON.stringify({ sync: "needsFull" })); - sessionStorage.removeItem("siftlode.activeAccount"); // account not pinned yet at mount window.history.replaceState({ sfPage: "channels" }, "", "/?ctab=hidden"); // only the tab in the URL const { result } = render(); - act(() => setActiveAccount(1)); // App pins the account after /api/me // The present dim (tab) is adopted from the link... expect(result.current.view).toBe("hidden"); diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index a5006e36..4725541d 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -9,12 +9,11 @@ import { useState, type ReactNode, } from "react"; -import { getAccountRaw, setAccountRaw, LS } from "../lib/storage"; +import { getAccountRaw, LS } from "../lib/storage"; import { parseChannelLayout, type ChannelLayout } from "../lib/channelLayout"; import { hydrateSort, type SortState } from "../lib/columnSort"; import { useSortState } from "../lib/useSortState"; import { useFilterState } from "../lib/useFilterState"; -import { useActiveAccountId } from "../lib/activeAccount"; import { useUrlMirror } from "../lib/useUrlMirror"; import { rememberNavPosition } from "../lib/history"; import { @@ -74,25 +73,16 @@ function readStoredFilters(): FilterMap { // present, the URL wins over this account's stored view — read once at mount and applied over the slice // hooks (see the provider). The keys are channels-unique, so their mere presence identifies a shared // manager view; no ?page check is needed (the mirror below is page-gated so it never pollutes others). -// The seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` matters -// because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns null), -// so an OMITTED dimension's base is a default, not this account's stored value. The provider seeds and -// persists only the PRESENT dimensions and lets the absent ones re-hydrate from storage normally — -// otherwise a partial shared link silently resets the omitted stored sort / filter map. -type ChannelsSeedPresent = { view: boolean; sort: boolean; filters: boolean }; -type ChannelsUrlSeed = { state: ChannelsUrlState; present: ChannelsSeedPresent }; -function readChannelsUrlSeed(): ChannelsUrlSeed | null { +// The base is this account's stored view (BootGate pins the account before this provider mounts), so +// a PARTIAL link — one carrying only the tab, say — keeps the stored sort and filter map for the rest. +function readChannelsUrlSeed(): ChannelsUrlState | null { const params = new URLSearchParams(window.location.search); if (!hasChannelsParams(params)) return null; - const state = channelsParamsToState(params, { + return channelsParamsToState(params, { view: clampView(getAccountRaw(LS.channelsView)), sort: hydrateSort(getAccountRaw(LS.channelSort), [], null), filters: readStoredFilters(), }); - return { - state, - present: { view: params.has("ctab"), sort: params.has("csort"), filters: params.has("cf") }, - }; } type ChannelsState = { @@ -168,10 +158,9 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { // The subscribed table's column-filter map, owned here (lifted out of DataTable) so the top status // chips, the Sync column funnel, and the card layout share one state; useFilterState persists it. const [filters, setFilters] = useFilterState(LS.channelsFilterMap, migrateSyncChip); - // view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook), so - // they get init-from-storage AND the account-pin re-hydrate for free (this provider mounts above - // , which pins the account only after /api/me). Stored un-clamped; clampView/parseChannelLayout - // heal them for display. setView layers the history logic (below) on top of the raw setter. + // view + layout are persisted tabs (usePersistedTab = the shared account-scoped tab-state hook). + // Stored un-clamped; clampView/parseChannelLayout heal them for display. setView layers the history + // logic (below) on top of the raw setter. const [viewRaw, setViewRaw] = usePersistedTab(LS.channelsView, "subscribed"); const view = clampView(viewRaw); const [layoutRaw, setLayout] = usePersistedTab(LS.channelLayout, "table"); @@ -185,43 +174,20 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { // S8 — shareable manager view. Captured once at mount (or null when no manager URL keys are present). const [urlSeed] = useState(readChannelsUrlSeed); - const accountId = useActiveAccountId(); const seededRef = useRef(false); - const persistedRef = useRef(false); - // Apply the shared-link seed once, BEFORE paint, through the slice setters. Going through the setters - // (not raw state) is deliberate: each one calls its hook's markDirty, so the account-pin re-hydrate - // (which fires later on a fresh tab) sees the slice as user-edited and won't reload the stored view - // over the shared one. setViewRaw (not setView) restores the tab without pushing a phantom history - // entry. A normal load has urlSeed === null and this is a no-op. + // Apply the shared-link seed once, BEFORE paint, through the slice setters — which persist it, so the + // shared view also survives a later PLAIN reload (no URL). setViewRaw (not setView) restores the tab + // without pushing a phantom history entry. A normal load has urlSeed === null and this is a no-op. useLayoutEffect(() => { if (!urlSeed || seededRef.current) return; seededRef.current = true; - // Only apply the dimensions the URL actually carried. An omitted dimension must NOT go through its - // setter (which would markDirty and block the account-pin re-hydrate from loading the stored value) - // — leave it to hydrate from storage normally. Fixes a partial link wiping the stored sort/filters. - const { state, present } = urlSeed; - if (present.view && state.view !== view) setViewRaw(state.view); - if (present.filters) setFilters(state.filters); - if (present.sort) setSort(state.sort); + if (urlSeed.view !== view) setViewRaw(urlSeed.view); + setFilters(urlSeed.filters); + setSort(urlSeed.sort); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // On a fresh tab the account pins AFTER mount, so the seed's setter writes above no-op'd (no account - // yet). Persist the seed to the account once it's known, so the shared view also survives a later - // PLAIN reload (no URL) — mirrors PlexProvider's rehydrate-persist. The slice hooks' own re-hydrate - // already skips (marked dirty above), so this only touches storage, not state. - useEffect(() => { - if (!urlSeed || accountId == null || persistedRef.current) return; - persistedRef.current = true; - // Persist ONLY the URL-present dimensions — the omitted ones were seeded from mount-time defaults - // (no account yet), so writing them here would overwrite the account's real stored sort/filter map. - const { state, present } = urlSeed; - if (present.view) setAccountRaw(LS.channelsView, state.view); - if (present.filters) setAccountRaw(LS.channelsFilterMap, JSON.stringify(state.filters)); - if (present.sort) setAccountRaw(LS.channelSort, JSON.stringify(state.sort)); - }, [urlSeed, accountId]); - // Latest-value ref so the STABLE setView can compare against the current tab without depending on // it (a dep would recreate the action on every switch and re-render every actions consumer). // Written during render — the sanctioned pattern NavigationProvider uses for `pageRef`. @@ -237,7 +203,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) { // Re-selecting the tab you are already on is not a navigation — bail before pushing, or clicking // the active tab (and goToFullHistory forcing "subscribed") stacks Back entries that do nothing. if (v === viewRef.current) return; - setViewRaw(v); // persists + guards the account-pin re-hydrate (via usePersistedTab) + setViewRaw(v); // persists (via usePersistedTab) // Only own an entry while the manager is already on screen. Arriving from elsewhere // (focusChannel / goToFullHistory) lets setPage push the entry — the stamp effect labels it — so // one navigation never costs two Back presses. diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index 40dfd30c..ef6d3aeb 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -8,14 +8,14 @@ import { type ReactNode, } from "react"; import { qk } from "../lib/queryKeys"; -import { useQuery } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { - api, getActiveAccount, coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES, type FeedFilters, + type Me, } from "../lib/api"; import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState"; import { useUrlMirror } from "../lib/useUrlMirror"; @@ -53,9 +53,8 @@ const DEFAULT_FILTERS: FeedFilters = { const FILTERS_KEY = LS.filters; -// This account's own persisted filters (per-account keys — never another account's). When the -// account isn't known yet (first load, before the tab is pinned), start from defaults; the post-login -// effect loads the real ones once the id arrives. +// This account's own persisted filters (per-account keys — never another account's). No account +// (signed out) → defaults. export function loadAccountFilters(id: number | null): FeedFilters { // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred @@ -87,10 +86,12 @@ export function loadAccountFilters(id: number | null): FeedFilters { } // URL wins over localStorage so a pasted link reproduces the exact view. -function loadInitialFilters(): FeedFilters { +function loadInitialFilters(me: Me | null | undefined): FeedFilters { const params = new URLSearchParams(window.location.search); if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); - return loadAccountFilters(getActiveAccount()); + const f = loadAccountFilters(getActiveAccount()); + // The shared demo account has no subscriptions, so default it to the whole library. + return me?.is_demo ? { ...f, scope: "all" } : f; } interface FeedFiltersActions { @@ -108,18 +109,18 @@ export function useFeedFiltersActions(): FeedFiltersActions { } export function FeedFiltersProvider({ children }: { children: ReactNode }) { - const [filters, setFiltersState] = useState(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(() => + loadInitialFilters(qc.getQueryData(qk.me())), + ); // Whether this load arrived with feed-filter params in the URL (a bookmark/shared link). Captured // once at mount — such filters win over the account's stored view and are persisted to the account. const [initialHadUrlFilters] = useState(() => hasFilterParams(new URLSearchParams(window.location.search)), ); - // Passive observer: App owns the fetching `qk.me()` query (gated on setup state); enabled:false - // here reads its cache and re-renders when it lands, without triggering a second fetch (which - // could 503 in setup mode). We only need id + is_demo, which don't change on background refetch. - const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); - const setFilters = useCallback((next: FeedFilters) => { setFiltersState(next); // Persist under THIS tab's active account so filters don't bleed between accounts. @@ -135,25 +136,12 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) { const { page } = useNavigation(); useUrlMirror(filterParamUpdates(page === "feed" ? filters : null)); - // Load THIS account's own filters when the account first resolves or changes (login / switch / - // add). Filters are per-account localStorage state, so another account's last view (incl. its - // default saved view) must never carry over. A share link's filters (captured at mount) win and - // are persisted to the account instead. Runs on id change only, so a 60s background refetch of - // the same account never clobbers the filters you're editing. + // A share link's filters (captured at mount) win over the account's stored view and are persisted + // to it once, so the view the link opened also survives a later plain reload. useEffect(() => { - const id = meQuery.data?.id; - if (id == null) return; - if (initialHadUrlFilters) { - const key = accountKey(FILTERS_KEY, id); - if (key) writeJSON(key, filters); - return; - } - let f = loadAccountFilters(id); - // The shared demo account has no subscriptions, so default it to the whole library. - if (meQuery.data?.is_demo) f = { ...f, scope: "all" }; - setFiltersState(f); + if (initialHadUrlFilters) setFilters(filters); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [meQuery.data?.id]); + }, []); const actions = useMemo(() => ({ setFilters }), [setFilters]); diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx index 8415cf53..3ac495fd 100644 --- a/frontend/src/components/KeyGate.tsx +++ b/frontend/src/components/KeyGate.tsx @@ -22,8 +22,8 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa }); const configured = keyQ.data?.configured ?? false; // Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts - // have no password to check. Reactive passive observer of the App-owned qk.me() query (enabled: - // false — App does the fetching), so hasPassword stays correct even if me resolves after mount. + // have no password to check. Passive observer of the qk.me() query BootGate fetches (enabled:false), + // so hasPassword follows the cache when a refetch changes it. const meQ = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); const hasPassword = meQ.data?.has_password ?? false; diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index b0d8be57..885d5975 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next"; import { urlWithParams, type Page } from "../lib/urlState"; import { pageTitleKey } from "../lib/pageMeta"; import { setAccountRaw } from "../lib/storage"; -import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { setNavigator } from "../lib/nav"; import { closeOverlaysThenNavigate, @@ -102,27 +101,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) { const channelViewRef = useRef(channelView); channelViewRef.current = channelView; - // The landing page is per-account (PAGE_KEY), but this provider mounts above , which pins the - // tab's account only after /api/me — so on a fresh tab `loadInitialPage` reads an unknown account and - // falls back to "feed". Re-read once the account is pinned and correct the CURRENT history entry - // (replaceState, not push) so Back isn't given a phantom "feed" step. markDirty (set by setPage) - // skips this if the user already navigated in the pre-pin window. - const markDirty = useAccountRehydrate(() => { - const stored = loadInitialPage(); - if (stored === pageRef.current) return; - setPageState(stored); - setAccountRaw(PAGE_KEY, stored); // no-op re-write, keeps storage authoritative - // Correct the CURRENT entry (replace, not push) and PRESERVE any markers already on it — a channel - // (_chan) or YT search (_yt) opened in the pre-pin window survives on top of the restored page, - // rather than being orphaned from history so Back can't close it. Mirror the corrected page to the - // address bar too (urlWithParams keeps any ?chan/filter params already there). - window.history.replaceState( - { ...(window.history.state || {}), sfPage: stored }, - "", - urlWithParams({ page: stored === "feed" ? null : stored }), - ); - }); - const setPage = useCallback( (next: Page) => // Never under an open overlay — see the interface doc. The refs are read when the push @@ -131,7 +109,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // A channel page overlays the content column, so a nav click must close it even when the // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). if (next === pageRef.current && !channelViewRef.current) return; - markDirty(); setChannelView(null); // leave any open channel page when navigating via the rail setPageState(next); setAccountRaw(PAGE_KEY, next); @@ -163,15 +140,12 @@ export function NavigationProvider({ children }: { children: ReactNode }) { ); notifyNav(); }), - [markDirty], + [], ); - // A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a - // pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's - // replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page. - // - // Like setPage it never lands under an open overlay: the player's channel link and the palette's - // channel rows call it from inside one. + // A sub-view (channel / YT search) is an OVERLAY, not a page change. Like setPage it never lands + // under an open overlay: the player's channel link and the palette's channel rows call it from + // inside one. const openChannel = useCallback((id: string, name?: string) => { closeOverlaysThenNavigate(() => { const url = urlWithParams({ chan: id }); @@ -197,7 +171,6 @@ export function NavigationProvider({ children }: { children: ReactNode }) { }, []); const enterYtSearch = useCallback((q: string) => { - // Overlay, not a page change — see openChannel: no markDirty, the re-hydrate preserves _yt. setYtSearch(q); // Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it // once the results resolve, so a reload only restores a confirmed scrape-sourced search. diff --git a/frontend/src/components/PlaylistsProvider.tsx b/frontend/src/components/PlaylistsProvider.tsx index 4ddf94fe..e6ac27b0 100644 --- a/frontend/src/components/PlaylistsProvider.tsx +++ b/frontend/src/components/PlaylistsProvider.tsx @@ -1,6 +1,5 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; import { LS, setAccountRaw } from "../lib/storage"; -import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { useUrlMirror } from "../lib/useUrlMirror"; import { useNavigation } from "./NavigationProvider"; import { readSelectedId } from "./playlistsInit"; @@ -43,19 +42,11 @@ export function PlaylistsProvider({ children }: { children: ReactNode }) { const [search, setSearchState] = useState(""); const [selectedId, setSelectedIdState] = useState(readSelectedId); - // Re-read the selected playlist once the account is pinned after mount (first load on a fresh tab): - // this provider mounts above , which pins the account only after /api/me. - const markDirty = useAccountRehydrate(() => setSelectedIdState(readSelectedId())); - const setSearch = useCallback((q: string) => setSearchState(q), []); - const setSelectedId = useCallback( - (id: number | null) => { - markDirty(); - setSelectedIdState(id); - if (id != null) setAccountRaw(LS.playlist, String(id)); - }, - [markDirty], - ); + const setSelectedId = useCallback((id: number | null) => { + setSelectedIdState(id); + if (id != null) setAccountRaw(LS.playlist, String(id)); + }, []); // Mirror the selected playlist to ?pl on the Playlists page so it's bookmarkable; clear it elsewhere // (the localStorage copy still restores it on return). useUrlMirror keeps ?page and the other nav diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx index cc68890d..dd9984cf 100644 --- a/frontend/src/components/PlexProvider.tsx +++ b/frontend/src/components/PlexProvider.tsx @@ -1,4 +1,12 @@ -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; import { parseSortState, type SortState } from "../lib/columnSort"; import { parsePlexField, type PlexSearchField } from "../lib/plexSearch"; @@ -10,7 +18,6 @@ import { type PlexUrlState, } from "../lib/plexUrlState"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; -import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { useUrlMirror } from "../lib/useUrlMirror"; import { useNavigation } from "./NavigationProvider"; @@ -20,10 +27,8 @@ import { useNavigation } from "./NavigationProvider"; // instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts, // stable useCallback setters, one-line hooks; composed in main.tsx around ). // -// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds -// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above -// , which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload -// (an account SWITCH does full-reload, but the FIRST load does not). `playlistOpen` is an ephemeral +// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount +// (BootGate pins the tab's account first; an account switch reloads). `playlistOpen` is an ephemeral // hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex // search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks // into (and is never greeted by a stale value from) the feed. @@ -95,7 +100,7 @@ export function usePlexActions(): PlexActions { return useContext(ActionsContext); } -// The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate. +// The per-account init readers, shared by the useState seeds and the shared-link seed's base. function readPlexScope(): string { const v = getAccountRaw(LS.plexLibrary) ?? "both"; return ["movie", "show", "both"].includes(v) ? v : "both"; @@ -144,45 +149,31 @@ function readPlexFilters(): PlexFilters { return EMPTY_PLEX_FILTERS; } } -// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present` -// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns -// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value. -// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the -// account's real stored value for the absent ones — otherwise a partial link silently resets them. -type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean }; -type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent }; -function readPlexUrlSeed(): PlexUrlSeed | null { +// The base is this account's stored view (BootGate pins the account before this provider mounts), so a +// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest. +function readPlexUrlSeed(): PlexUrlState | null { const params = new URLSearchParams(window.location.search); if (!hasPlexParams(params)) return null; - const state = plexParamsToState(params, { + return plexParamsToState(params, { scope: readPlexScope(), sort: readPlexSort(), show: readPlexShow(), filters: readPlexFilters(), }); - return { - state, - present: { - scope: params.has("pscope"), - sort: params.has("psort"), - show: params.has("pshow"), - filters: params.has("pf"), - }, - }; } export function PlexProvider({ children }: { children: ReactNode }) { - // Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin, - // is persisted to the account instead of being clobbered by the stored view (see the rehydrate). + // Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to + // the account (the effect further down), so the shared view also survives a later plain reload. const [urlSeed] = useState(readPlexUrlSeed); // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library). // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".) - const [scope, setScopeState] = useState(() => urlSeed?.state.scope ?? readPlexScope()); - const [show, setShowState] = useState(() => urlSeed?.state.show ?? readPlexShow()); - const [sort, setSortState] = useState(() => urlSeed?.state.sort ?? readPlexSort()); + const [scope, setScopeState] = useState(() => urlSeed?.scope ?? readPlexScope()); + const [show, setShowState] = useState(() => urlSeed?.show ?? readPlexShow()); + const [sort, setSortState] = useState(() => urlSeed?.sort ?? readPlexSort()); // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). const [filtersRaw, setFiltersRaw] = useState(() => - urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(), + urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(), ); const filters = useMemo(() => { try { @@ -203,87 +194,42 @@ export function PlexProvider({ children }: { children: ReactNode }) { // PlexBrowse consumes it into an info/show history subview then clears it. const [jumpTo, setJumpTo] = useState(null); - // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab). - // A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL - // state to this account (so the shared view wins and survives a later plain reload), and adopt only the - // non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. - const markDirty = useAccountRehydrate(() => { - if (urlSeed) { - // Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist - // it to this now-pinned account (so it wins and survives a later plain reload). A dimension the - // URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that - // default over the account's real stored value — re-read the stored value instead, exactly like a - // normal load. This is the fix for a partial shared link silently resetting the omitted slices. - const { state, present } = urlSeed; - if (present.scope) setAccountRaw(LS.plexLibrary, state.scope); - else setScopeState(readPlexScope()); - if (present.show) setAccountRaw(LS.plexShow, state.show); - else setShowState(readPlexShow()); - if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort)); - else setSortState(readPlexSort()); - if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters)); - else setFiltersRaw(readPlexFiltersRaw()); - setQState(readPlexQ()); - setFieldState(readPlexField()); - return; - } - setScopeState(readPlexScope()); - setShowState(readPlexShow()); - setSortState(readPlexSort()); - setFiltersRaw(readPlexFiltersRaw()); - setQState(readPlexQ()); - setFieldState(readPlexField()); - }); + // Persist a shared link's view to the account once, so it wins over the stored view and survives a + // later plain reload. The seed's base was the stored view, so a dimension the link omitted is written + // back unchanged. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. + useEffect(() => { + if (!urlSeed) return; + setAccountRaw(LS.plexLibrary, urlSeed.scope); + setAccountRaw(LS.plexShow, urlSeed.show); + setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort)); + setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters)); + }, [urlSeed]); - const setScope = useCallback( - (v: string) => { - markDirty(); - setScopeState(v); - setAccountRaw(LS.plexLibrary, v); - }, - [markDirty], - ); - const setShow = useCallback( - (v: string) => { - markDirty(); - setShowState(v); - setAccountRaw(LS.plexShow, v); - }, - [markDirty], - ); - const setSort = useCallback( - (v: SortState) => { - markDirty(); - setSortState(v); - setAccountRaw(LS.plexSort, JSON.stringify(v)); - }, - [markDirty], - ); - const setFilters = useCallback( - (f: PlexFilters) => { - markDirty(); - const s = JSON.stringify(f); - setFiltersRaw(s); - setAccountRaw(LS.plexFilters, s); - }, - [markDirty], - ); - const setQ = useCallback( - (v: string) => { - markDirty(); - setQState(v); - setAccountRaw(LS.plexQ, v); - }, - [markDirty], - ); - const setField = useCallback( - (v: PlexSearchField) => { - markDirty(); - setFieldState(v); - setAccountRaw(LS.plexField, v); - }, - [markDirty], - ); + const setScope = useCallback((v: string) => { + setScopeState(v); + setAccountRaw(LS.plexLibrary, v); + }, []); + const setShow = useCallback((v: string) => { + setShowState(v); + setAccountRaw(LS.plexShow, v); + }, []); + const setSort = useCallback((v: SortState) => { + setSortState(v); + setAccountRaw(LS.plexSort, JSON.stringify(v)); + }, []); + const setFilters = useCallback((f: PlexFilters) => { + const s = JSON.stringify(f); + setFiltersRaw(s); + setAccountRaw(LS.plexFilters, s); + }, []); + const setQ = useCallback((v: string) => { + setQState(v); + setAccountRaw(LS.plexQ, v); + }, []); + const setField = useCallback((v: PlexSearchField) => { + setFieldState(v); + setAccountRaw(LS.plexField, v); + }, []); const state = useMemo( () => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }), diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx index 87229d8b..65112227 100644 --- a/frontend/src/components/PrefsProvider.tsx +++ b/frontend/src/components/PrefsProvider.tsx @@ -3,14 +3,15 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { qk } from "../lib/queryKeys"; -import { useQuery } from "@tanstack/react-query"; -import { api } from "../lib/api"; +import { useQueryClient } from "@tanstack/react-query"; +import { api, type Me } from "../lib/api"; import { applyTheme, DEFAULT_THEME, @@ -53,35 +54,47 @@ export function usePrefs(): PrefsController { } export function PrefsProvider({ children }: { children: ReactNode }) { - const [theme, setThemeState] = useState(() => loadLocalTheme()); - const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1"); - const [hints, setHints] = useState(() => hintsEnabled()); - const [notif, setNotif] = useState(() => getNotifSettings()); - // The last server-known prefs. Written on adopt + after each save so the auto-save effect can - // compare against it and avoid echoing a server-adopted value straight back. A ref (drives no UI). - const baselineRef = useRef({ - theme: loadLocalTheme(), - performanceMode: getAccountRaw(PERF_KEY) === "1", - hints: hintsEnabled(), - notifications: getNotifSettings(), + const qc = useQueryClient(); + // The account's prefs, seeded straight from the `me` cache — BootGate mounts this only once /api/me + // has answered, so a theme changed on another device is right on the first paint instead of + // switching after it. This browser's copy fills any key the server has never stored. + const [seed] = useState(() => { + const prefs = qc.getQueryData(qk.me())?.preferences; + return { + theme: prefs?.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(), + performanceMode: + typeof prefs?.performanceMode === "boolean" + ? prefs.performanceMode + : getAccountRaw(PERF_KEY) === "1", + hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(), + notifications: prefs?.notifications + ? { ...getNotifSettings(), ...prefs.notifications } + : getNotifSettings(), + }; }); + const [theme, setThemeState] = useState(seed.theme); + const [perf, setPerf] = useState(seed.performanceMode); + const [hints, setHints] = useState(seed.hints); + const [notif, setNotif] = useState(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(seed); const [saveState, setSaveState] = useState("idle"); const saveMsgTimer = useRef(undefined); const saveTimer = useRef(undefined); - // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). - const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); - // Live-apply the prefs locally for instant preview (their localStorage mirrors update via the - // stores too); persistence to the server is the auto-save effect below. - useEffect(() => applyTheme(theme), [theme]); - useEffect(() => { + // stores too); persistence to the server is the auto-save effect below. The look is applied in + // LAYOUT effects, before the browser paints, so the app's first frame is already in the account's + // theme rather than in the baseline one main.tsx sets for the boot screen. + useLayoutEffect(() => applyTheme(theme), [theme]); + useLayoutEffect(() => { document.documentElement.dataset.perf = perf ? "1" : ""; setAccountRaw(PERF_KEY, perf ? "1" : "0"); }, [perf]); // The per-scheme background image (the "glass over image" look) is on when the user hasn't opted // out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the // theme-appropriate image on and switches the glass to translucent. - useEffect(() => { + useLayoutEffect(() => { const on = theme.bgImage && !perf; document.documentElement.dataset.backdrop = on ? "on" : "off"; }, [theme.bgImage, perf]); @@ -132,31 +145,6 @@ export function PrefsProvider({ children }: { children: ReactNode }) { [], ); - // On login / account switch, adopt the server-stored prefs into the live states AND the auto-save - // baseline (so adopting them doesn't echo a save back). - useEffect(() => { - const prefs = me?.preferences; - if (!prefs) return; - const adopted: EditablePrefs = { - theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(), - performanceMode: - typeof prefs.performanceMode === "boolean" - ? prefs.performanceMode - : getAccountRaw(PERF_KEY) === "1", - hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(), - notifications: prefs.notifications - ? { ...getNotifSettings(), ...prefs.notifications } - : getNotifSettings(), - }; - setThemeState(adopted.theme); - saveLocalTheme(adopted.theme); - setPerf(adopted.performanceMode); - setHints(adopted.hints); - setNotif(adopted.notifications); - baselineRef.current = adopted; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [me?.id]); - // Theme applies live (and caches to localStorage); the auto-save effect persists it to the server. const setTheme = useCallback((next: ThemePrefs) => { setThemeState(next); diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx index 6a6a8a9b..ea9d1459 100644 --- a/frontend/src/components/SavedViewsProvider.dom.test.tsx +++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx @@ -43,7 +43,7 @@ vi.mock("./PlexProvider", () => ({ vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true })); import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider"; -import { api, setActiveAccount, type SavedView } from "../lib/api"; +import { api, type SavedView } from "../lib/api"; import { LS, accountKey } from "../lib/storage"; import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount"; import { qk } from "../lib/queryKeys"; @@ -109,21 +109,6 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => { expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" }); }); - it("feed: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => { - // The provider mounts above , which pins the tab-account only after /api/me; the ungated - // views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its - // one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload). - sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY); - await mountResolved([ - view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }), - ]); - expect(h.setFeedFilters).not.toHaveBeenCalled(); - // App pins the account; the reactive id re-fires the effect and the deferred seed lands. - act(() => setActiveAccount(ID)); - await waitFor(() => expect(h.setFeedFilters).toHaveBeenCalledTimes(1)); - expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" }); - }); - it("feed: does NOT seed when the account already has stored filters", async () => { localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" })); await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]); diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index 8ab08b67..47ae71db 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -25,7 +25,6 @@ import { filtersToParams, hasFilterParams } from "../lib/urlState"; import { pickDefaultToSeed, preferSticky } from "../lib/savedViews"; import { notifyActionError } from "../lib/actionErrors"; import { accountKey, LS } from "../lib/storage"; -import { useActiveAccountId } from "../lib/activeAccount"; import { useConfirm } from "./ConfirmProvider"; import { useNavigation } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -179,19 +178,13 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { // semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's // login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a // reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a - // 60s background refetch never re-seeds over the filters you're editing. - // - // WAIT FOR THE ACCOUNT PIN. This provider mounts above , which pins the tab-account only after - // /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null. - // canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored") - // with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never - // re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the - // pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real. - const accountId = useActiveAccountId(); + // 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the + // per-account stored-filters key, which is always readable here: BootGate pins the tab's account + // before this provider mounts. const seededRef = useRef>(new Set()); useEffect(() => { const data = viewsQuery.data; - if (!data || accountId == null || seededRef.current.has(source)) return; + if (!data || seededRef.current.has(source)) return; seededRef.current.add(source); const def = pickDefaultToSeed(data, cfg.canSeedDefault()); if (!def) return; @@ -200,7 +193,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { // cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg // (which re-memoizes on every filter edit) is deliberately kept out of the deps. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewsQuery.data, source, accountId]); + }, [viewsQuery.data, source]); const currentKey = cfg.keyOf(cfg.filters); const activeView = useMemo( diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 68d95e3b..df18305e 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -131,10 +131,9 @@ function Appearance({ prefs }: { prefs: PrefsController }) { // Controlled by App's prefs draft: each change applies locally for preview but is only // persisted on an explicit Save (handled by the panel's Save/Discard bar). const { theme, setTheme, perf, setPerf, hints, setHints } = prefs; - // The landing page (E24). NOT a server preference like the rest of this tab: `loadInitialPage` - // has to answer before /api/me lands, so the value must be readable synchronously from - // localStorage — a landing module decided after the first paint would be a visible jump. The two - // stored values come from navigationInit, which is what reads them back at boot. + // The landing page (E24). Per-account localStorage, not a server preference like the rest of this + // tab: `loadInitialPage` reads it synchronously in NavigationProvider's initializer. The two stored + // values come from navigationInit, which is what reads them back at boot. const [startOnHome, setStartOnHome] = useAccountPersistedState(LS.startOnHome, START_ON_HOME.on); return ( diff --git a/frontend/src/components/WizardProvider.tsx b/frontend/src/components/WizardProvider.tsx index c56e7a69..078f22b1 100644 --- a/frontend/src/components/WizardProvider.tsx +++ b/frontend/src/components/WizardProvider.tsx @@ -22,8 +22,8 @@ const OnboardingWizard = lazy(() => import("./OnboardingWizard")); // // The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from // granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from -// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (App owns the -// fetching query), so it re-runs the auto-open check when the account resolves or its grants change. +// Settings / the notify "reconnect" action. It reads `me` as a passive qk.me() observer (BootGate owns +// the fetching query), so it re-runs the auto-open check when the account's grants change. interface WizardActions { openWizard: () => void; @@ -43,7 +43,7 @@ export function useWizard(): WizardActions { export function WizardProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); - // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). + // Passive observer of the account BootGate fetches (enabled:false → reads cache, re-renders on update). const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); const openWizard = useCallback(() => setOpen(true), []); diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx index f41a85ed..ac5361fe 100644 --- a/frontend/src/components/moduleRegistry.tsx +++ b/frontend/src/components/moduleRegistry.tsx @@ -1,4 +1,5 @@ -import { lazy, type ComponentType } from "react"; +import type { ComponentType } from "react"; +import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload"; import type { Page } from "../lib/urlState"; import type { PanelId, PanelLayout } from "../lib/panelLayout"; import { useMe } from "../lib/useMe"; @@ -21,22 +22,24 @@ import { useSavedViews } from "./SavedViewsProvider"; // its own isolated scope to directly — see the scope note in FeedFiltersProvider). // Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach -// non-admins; the logged-out landing pulls only the shell). Rendered inside App's . -const Home = lazy(() => import("./Home")); -const Feed = lazy(() => import("./Feed")); -const Channels = lazy(() => import("./Channels")); -const Playlists = lazy(() => import("./Playlists")); -const PlexBrowse = lazy(() => import("./PlexBrowse")); -const PlexSidebar = lazy(() => import("./PlexSidebar")); -const NotificationsPanel = lazy(() => import("./NotificationsPanel")); -const Messages = lazy(() => import("./Messages")); -const DownloadCenter = lazy(() => import("./DownloadCenter")); -const Stats = lazy(() => import("./Stats")); -const Scheduler = lazy(() => import("./Scheduler")); -const ConfigPanel = lazy(() => import("./ConfigPanel")); -const AdminUsers = lazy(() => import("./AdminUsers")); -const AuditLog = lazy(() => import("./AuditLog")); -const SettingsPanel = lazy(() => import("./SettingsPanel")); +// non-admins; the logged-out landing pulls only the shell). Rendered inside App's . Each +// one is preloadable: App fetches the chunks of every page the account can reach once the first page +// is on screen (preloadPages below), so switching modules never shows the chunk fallback. +const Home = lazyWithPreload(() => import("./Home")); +const Feed = lazyWithPreload(() => import("./Feed")); +const Channels = lazyWithPreload(() => import("./Channels")); +const Playlists = lazyWithPreload(() => import("./Playlists")); +const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse")); +const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar")); +const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel")); +const Messages = lazyWithPreload(() => import("./Messages")); +const DownloadCenter = lazyWithPreload(() => import("./DownloadCenter")); +const Stats = lazyWithPreload(() => import("./Stats")); +const Scheduler = lazyWithPreload(() => import("./Scheduler")); +const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel")); +const AdminUsers = lazyWithPreload(() => import("./AdminUsers")); +const AuditLog = lazyWithPreload(() => import("./AuditLog")); +const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel")); // The GLOBAL feed page: wires to the app-wide feed filters / view / live-search. (The channel // page renders itself with its own channel-scoped filters, so those two never share state.) @@ -80,8 +83,9 @@ function MessagesPage() { } export type PageDef = { - // Prop-free by construction: reads `me` + module state from hooks/providers. - Component: ComponentType; + // Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so + // a new page cannot silently opt out of preloadPages. + Component: PreloadableComponent; // Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade // would sit on it and flip on with the first scroll, so they turn it off. Defaults to on. fadeTop?: boolean; @@ -94,12 +98,13 @@ export type PageDef = { // page id → the component App renders in the content column (+ its shell chrome config). export const PAGE_CONTENT: Record = { home: { Component: Home, maxWidth: "80rem" }, - feed: { Component: FeedPage }, + // The two wrappers preload the lazy chunk they render. + feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) }, channels: { Component: Channels, fadeTop: false, maxWidth: "96rem" }, playlists: { Component: Playlists, fadeTop: false, maxWidth: "64rem" }, plex: { Component: PlexBrowse, maxWidth: "1600px" }, notifications: { Component: NotificationsPanel, maxWidth: "48rem" }, - messages: { Component: MessagesPage }, + messages: { Component: Object.assign(MessagesPage, { preload: Messages.preload }) }, downloads: { Component: DownloadCenter, maxWidth: "56rem" }, stats: { Component: Stats }, scheduler: { Component: Scheduler }, @@ -134,3 +139,13 @@ export const PAGE_RAIL: Partial> = { plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" }, playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" }, }; + +/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are + * swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */ +export function preloadPages(pages: Page[]): void { + for (const p of pages) { + PAGE_CONTENT[p].Component.preload().catch(() => {}); + const rail = PAGE_RAIL[p]?.Rail as Partial> | undefined; + rail?.preload?.().catch(() => {}); + } +} diff --git a/frontend/src/components/ui/QueryState.tsx b/frontend/src/components/ui/QueryState.tsx index 2b18abb2..cfdd1062 100644 --- a/frontend/src/components/ui/QueryState.tsx +++ b/frontend/src/components/ui/QueryState.tsx @@ -6,11 +6,17 @@ import { Button } from "./Button"; // Centered loading state: a spinning accent ring + an optional label. The single shared loading // visual for a page/section's first load, so all of them read the same (and can be swapped for // skeletons later in one place). `role=status`/`aria-live` announces it to screen readers. +// +const LOADING_REVEAL_DELAY = "300ms"; +// It fades in only after LOADING_REVEAL_DELAY: most first loads here answer in tens of milliseconds, +// and a spinner shown for two frames before the content replaces it reads as a flicker, not as +// feedback. A load that really takes a while still gets the spinner, a third of a second in. export function LoadingState({ label, className = "" }: { label?: ReactNode; className?: string }) { const { t } = useTranslation(); return (
diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index fca55657..f85f17e8 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -32,7 +32,7 @@ export function isSupported(code: string | null | undefined): code is LangCode { } // Initial language before the server preference is known: stored choice, else the browser -// language, else English. (After login, App adopts the server-persisted preference.) +// language, else English. (Signed in, BootGate adopts the account's preference before the app renders.) function detectInitialLang(): LangCode { const stored = localStorage.getItem(LANG_KEY); if (isSupported(stored)) return stored; diff --git a/frontend/src/lib/activeAccount.ts b/frontend/src/lib/activeAccount.ts index 65cb6ab8..2b7b6e9b 100644 --- a/frontend/src/lib/activeAccount.ts +++ b/frontend/src/lib/activeAccount.ts @@ -1,9 +1,10 @@ -import { useSyncExternalStore } from "react"; - -// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts), plus its reactive -// wrapper. Its OWN module (imports nothing internal) so it can be shared by both `storage.ts` (which -// keys per-account localStorage on it) and `useAccountRehydrate.ts` without a circular import — the -// account id is a lower-level primitive than the storage helpers that consume it. +// The account a browser TAB is acting as (per-tab, in sessionStorage — see api.ts). Its OWN module +// (imports nothing internal) so it can be shared by `storage.ts`, which keys per-account localStorage +// on it, without a circular import — the account id is a lower-level primitive than the storage +// helpers that consume it. +// +// Deliberately NOT reactive: BootGate pins the account before any per-account state is read, and a +// switch reloads the page, so nothing mounted ever sees the id change underneath it. // // ACTIVE_ACCOUNT_KEY is duplicated here rather than imported from api.ts so this stays dependency-free. export const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount"; @@ -29,22 +30,3 @@ export function withActiveAccount(path: string): string { if (id == null) return path; return `${path}${path.includes("?") ? "&" : "?"}account=${id}`; } - -// Reactive active-account. setActiveAccount/clearActiveAccount write sessionStorage — a silent op that -// triggers no React render — so this tab's per-account state (sort/filter/view/layout, all keyed by the -// account) would never re-read once the account is finally known. That bites exactly once, on first -// load: the provider tree above mounts with NO account, then 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); -} diff --git a/frontend/src/lib/api/core.test.ts b/frontend/src/lib/api/core.test.ts index b219747d..17637bd2 100644 --- a/frontend/src/lib/api/core.test.ts +++ b/frontend/src/lib/api/core.test.ts @@ -19,7 +19,6 @@ vi.mock("../../i18n", () => ({ default: { t: (k: string) => k, language: "en" } vi.mock("../activeAccount", () => ({ activeAccountId: () => null, ACTIVE_ACCOUNT_KEY: "k", - notifyActiveAccountChange: vi.fn(), })); import { diff --git a/frontend/src/lib/api/core.ts b/frontend/src/lib/api/core.ts index a629dff1..a7db8c3c 100644 --- a/frontend/src/lib/api/core.ts +++ b/frontend/src/lib/api/core.ts @@ -6,7 +6,7 @@ import { getActiveToasts, notify, remove } from "../notifications"; import i18n from "../../i18n"; import { formatBytes } from "../format"; import { reportError } from "../errorDialog"; -import { activeAccountId, ACTIVE_ACCOUNT_KEY, notifyActiveAccountChange } from "../activeAccount"; +import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "../activeAccount"; /** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB"). * Lives here so the request builders can stamp it themselves: a signal that has to be passed in by @@ -268,8 +268,6 @@ export function setActiveAccount(id: number): void { } catch { /* ignore */ } - // Let per-account UI state re-hydrate now the account is known (see notifyActiveAccountChange). - notifyActiveAccountChange(); } export function clearActiveAccount(): void { try { @@ -277,7 +275,6 @@ export function clearActiveAccount(): void { } catch { /* ignore */ } - notifyActiveAccountChange(); } export async function req( diff --git a/frontend/src/lib/lazyWithPreload.dom.test.tsx b/frontend/src/lib/lazyWithPreload.dom.test.tsx new file mode 100644 index 00000000..bf919cd1 --- /dev/null +++ b/frontend/src/lib/lazyWithPreload.dom.test.tsx @@ -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 ; +} +const module = (): Promise<{ default: ComponentType }> => + Promise.resolve({ default: Counter }); +const inSuspense = (C: ComponentType) => ( + fallback}> + + +); + +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) => ( + fallback}> + shell {tick} + {show && } + + ); + 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 }>(new Error("offline")) + : module(), + ); + await expect(Page.preload()).rejects.toThrow("offline"); + await act(() => Page.preload()); + expect(calls).toBe(2); + }); +}); diff --git a/frontend/src/lib/lazyWithPreload.ts b/frontend/src/lib/lazyWithPreload.ts new file mode 100644 index 00000000..fd7da657 --- /dev/null +++ b/frontend/src/lib/lazyWithPreload.ts @@ -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

= ComponentType

& { + /** Fetch the component's code now. Idempotent; a failed fetch is retried by the next call. */ + preload: () => Promise; +}; + +export function lazyWithPreload

( + factory: () => Promise<{ default: ComponentType

}>, +): PreloadableComponent

{ + let loaded: ComponentType

| null = null; + let pending: Promise | 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

}))); + + function Preloadable(props: P) { + return createElement(loaded ?? (Lazy as unknown as ComponentType

), props); + } + return Object.assign(Preloadable, { preload }); +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index c260fb03..d6386f72 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; import { activeAccountId } from "./activeAccount"; -import { useAccountRehydrate } from "./useAccountRehydrate"; // Central registry of every localStorage key the app uses. One place to see them all, rename // safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across @@ -16,15 +15,13 @@ export const LS = { filters: "siftlode.filters", page: "siftlode.page", // E24: start every session on the Home surface rather than on the module you happened to leave - // open. Per-account, "0" = opt out and go back to remembering the last module. It has to be - // localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands — - // a landing page decided after the first paint would be a visible jump. + // open. Per-account, "0" = opt out and go back to remembering the last module. Read by + // `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's + // account is pinned — so the landing is decided right before the first paint, never corrected after. startOnHome: "siftlode.startOnHome", perfMode: "siftlode.perfMode", - // The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an - // established tab paints your view straight away. A brand-new tab still starts on the default - // until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so - // the per-account key can't be read. + // The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds + // from only when the server has never stored the key. feedView: "siftlode.feedView", // The subscribed table's column filters (a FilterMap), lifted out of the table so the top status // chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes @@ -72,7 +69,7 @@ export const LS = { } as const; // --- per-account scoping ----------------------------------------------------------------- -// The active-account primitive (activeAccountId / useActiveAccountId / notify) lives in ./activeAccount +// The active-account primitive (activeAccountId) lives in ./activeAccount // so it can be shared without a cycle; the helpers below key localStorage on it. /** Suffix a base localStorage key with an account id so per-account UI state (filters, selected @@ -154,24 +151,21 @@ export function writeJSON(key: string, value: unknown): void { /** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see * accountKey), so one account's persisted tab/view position doesn't carry over to another in the * same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it - * can be used before an async-loaded option list is known, keeping hook order stable. Consumers - * usually mount only once signed in (account known), but re-read on the account-pin transition too so - * one that mounts before the pin (a fresh tab's first paint) still restores. */ + * can be used before an async-loaded option list is known, keeping hook order stable. Read once, at + * mount: BootGate mounts every consumer after the account is pinned. */ export function useAccountPersistedState( base: string, fallback = "", ): [string, (value: string) => void] { const [value, setValue] = useState(() => getAccountRaw(base) ?? fallback); - const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback)); - // Stable setter (base + markDirty are both stable) so a provider can build a stable action on top - // of it — e.g. ChannelsProvider's setView, which must not change identity on every tab switch. + // Stable setter (base is stable) so a provider can build a stable action on top of it — e.g. + // ChannelsProvider's setView, which must not change identity on every tab switch. const set = useCallback( (next: string) => { - markDirty(); setValue(next); setAccountRaw(base, next); }, - [base, markDirty], + [base], ); return [value, set]; } @@ -184,17 +178,15 @@ export function useAccountPersistedObject( defaults: T, ): [T, (patch: Partial) => void] { const [value, setValue] = useState(() => readAccountMerged(base, defaults)); - const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults))); const patch = useCallback( (p: Partial) => { - markDirty(); setValue((prev) => { const next = { ...prev, ...p }; writeAccount(base, next); return next; }); }, - [base, markDirty], + [base], ); return [value, patch]; } diff --git a/frontend/src/lib/useAccountRehydrate.dom.test.ts b/frontend/src/lib/useAccountRehydrate.dom.test.ts deleted file mode 100644 index 8ba5cacd..00000000 --- a/frontend/src/lib/useAccountRehydrate.dom.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/frontend/src/lib/useAccountRehydrate.ts b/frontend/src/lib/useAccountRehydrate.ts deleted file mode 100644 index 51f7cdac..00000000 --- a/frontend/src/lib/useAccountRehydrate.ts +++ /dev/null @@ -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 mounts before the account is pinned, so its useState initializers seed from -// an unknown account (empty defaults); once 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; - }, []); -} diff --git a/frontend/src/lib/useFilterState.dom.test.ts b/frontend/src/lib/useFilterState.dom.test.ts index c6d5655a..44c36908 100644 --- a/frontend/src/lib/useFilterState.dom.test.ts +++ b/frontend/src/lib/useFilterState.dom.test.ts @@ -6,26 +6,19 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; -// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the -// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned. -const { store, ctl } = vi.hoisted(() => ({ - store: new Map(), - ctl: { account: 1 as number | null }, -})); +const { store } = vi.hoisted(() => ({ store: new Map() })); vi.mock("./storage", () => ({ - getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)), + getAccountRaw: (k: string) => store.get(k) ?? null, setAccountRaw: (k: string, v: string) => { - if (ctl.account != null) store.set(k, v); + store.set(k, v); }, })); -vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account })); import { useFilterState } from "./useFilterState"; describe("useFilterState", () => { beforeEach(() => { store.clear(); - ctl.account = 1; }); it("hydrates to an empty map when storage is empty", () => { @@ -77,42 +70,4 @@ describe("useFilterState", () => { // A seed that returns null falls back to an empty map. expect(renderHook(() => useFilterState("k3", () => null)).result.current[0]).toEqual({}); }); - - // The account-pin re-hydrate (via useAccountRehydrate) integrated into useFilterState. - it("re-hydrates the stored map once the account is pinned after mount", () => { - ctl.account = null; // fresh tab: no account at mount - store.set("k", JSON.stringify({ sync: "fully", tags: ["7"] })); - const { result, rerender } = renderHook(() => useFilterState("k")); - expect(result.current[0]).toEqual({}); // mount read with no account → empty - act(() => { - ctl.account = 1; // 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" - }); }); diff --git a/frontend/src/lib/useFilterState.ts b/frontend/src/lib/useFilterState.ts index 29ffd1d6..e2171181 100644 --- a/frontend/src/lib/useFilterState.ts +++ b/frontend/src/lib/useFilterState.ts @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; import { getAccountRaw, setAccountRaw } from "./storage"; -import { useAccountRehydrate } from "./useAccountRehydrate"; import type { FilterMap } from "./tableColumns"; // The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the @@ -14,22 +13,17 @@ export function useFilterState( // flash); it stays idempotent because the seed persists as soon as the user touches the filters. migrate?: () => FilterMap | null, ): [FilterMap, (next: FilterMap) => void] { - // Read the account's stored map, or the migrate seed when its key is still empty. Shared by the - // initial mount AND the account-pin re-hydrate, so first-load migration runs on whichever fires with - // the account known (on a fresh tab the account is null at mount → the re-hydrate does the real read). - const read = useCallback((): FilterMap => { + // The account's stored map, or the migrate seed when its key is still empty. + const [filters, setFilters] = useState(() => { const raw = getAccountRaw(storageBase); return raw ? hydrate(raw) : (migrate?.() ?? {}); - }, [storageBase, migrate]); - const [filters, setFilters] = useState(read); - const markDirty = useAccountRehydrate(() => setFilters(read())); + }); const set = useCallback( (next: FilterMap) => { - markDirty(); setFilters(next); setAccountRaw(storageBase, JSON.stringify(next)); }, - [storageBase, markDirty], + [storageBase], ); return [filters, set]; } diff --git a/frontend/src/lib/useMe.ts b/frontend/src/lib/useMe.ts index d5f126b0..4f5b0c99 100644 --- a/frontend/src/lib/useMe.ts +++ b/frontend/src/lib/useMe.ts @@ -2,8 +2,8 @@ import { useQuery } from "@tanstack/react-query"; import { qk } from "./queryKeys"; import { api, type Me } from "./api"; -// The signed-in account, read from the qk.me() query cache. App owns the FETCHING query (gated on -// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the +// The signed-in account, read from the qk.me() query cache. BootGate owns the FETCHING query (gated on +// setup state) and App renders modules only once it has resolved to a real user — so anywhere inside the // authenticated module tree the cache is always populated. `enabled:false` makes this a passive // observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache // updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook diff --git a/frontend/src/lib/usePref.dom.test.ts b/frontend/src/lib/usePref.dom.test.ts index e382b524..05c5bc67 100644 --- a/frontend/src/lib/usePref.dom.test.ts +++ b/frontend/src/lib/usePref.dom.test.ts @@ -2,10 +2,10 @@ // // usePref: the one per-account + server preference hook. Covers the three seed sources (server cache / // localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror), -// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the +// debounced writes, parse, and the raw useSavePref persister. The `me` cache is the // server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { renderHook, act, waitFor } from "@testing-library/react"; +import { renderHook, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createElement, type ReactNode } from "react"; @@ -125,19 +125,6 @@ describe("usePref", () => { expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" }); }); - it("adopts the server value when the account id changes (login / switch)", async () => { - const qc = makeClient(); - seedMe(qc, { view: "cards" }, 1); - const { result } = renderHook(() => usePref("view", "cards"), { - wrapper: wrapperFor(qc), - }); - expect(result.current[0]).toBe("cards"); - // A different account resolves. The passive me observer notifies asynchronously (react-query's - // notifyManager batches), so wait for the re-render — the same path the real App drives on login. - act(() => seedMe(qc, { view: "tiles" }, 2)); - await waitFor(() => expect(result.current[0]).toBe("tiles")); - }); - describe("debounceMs", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); diff --git a/frontend/src/lib/usePref.ts b/frontend/src/lib/usePref.ts index 66b6ced2..8b0bc7d0 100644 --- a/frontend/src/lib/usePref.ts +++ b/frontend/src/lib/usePref.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { qk } from "./queryKeys"; import { api } from "./api"; import type { Me } from "./api/account"; @@ -14,24 +14,19 @@ const reportPrefSaveFailure = (err: unknown) => notifyActionError(err, i18n.t("errors.writeFailed.prefs")); // The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to -// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted -// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its -// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache, -// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds -// all three into one hook so a new server-synced pref is a one-liner. +// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the +// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and +// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new +// server-synced pref is a one-liner. // -// Two mirror strategies, unified here (pick via `localKey`): -// • Providers mounted ABOVE (feed view, panel layouts, rail-collapse flags) render before -// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on -// reload — pass `localKey`. -// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they -// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`. -// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other +// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the +// app tree only once /api/me has answered, keyed on the account, so there is nothing to adopt after +// mount. The source of truth is the server; the `me` cache is kept in sync optimistically so other // consumers reading the same key (e.g. a second player instance) see the change at once. export interface UsePrefOptions { - /** Also mirror to per-account localStorage under this base key, and seed synchronously from it on - * first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */ + /** Also mirror to per-account localStorage under this base key; that copy seeds the value when the + * server payload lacks the key. */ localKey?: string; /** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The * local state + cache still update instantly; only the network write is deferred. */ @@ -70,8 +65,8 @@ export function useSavePref(): (patch: Record) => void { ); } -/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with - * automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */ +/** A per-account preference persisted to the server (and optionally mirrored to localStorage). + * Returns `[value, setValue]`; the setter is stable. */ export function usePref( serverKey: string, defaultValue: T, @@ -79,8 +74,6 @@ export function usePref( ): [T, (next: T) => void] { const { localKey, debounceMs, parse } = options; const qc = useQueryClient(); - // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). - const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false }); // Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which // would churn the memoized setter and the adopt effect). parse must be idempotent anyway. @@ -141,20 +134,5 @@ export function usePref( [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(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]; } diff --git a/frontend/src/lib/useSortState.dom.test.ts b/frontend/src/lib/useSortState.dom.test.ts index 758e4249..b77c1155 100644 --- a/frontend/src/lib/useSortState.dom.test.ts +++ b/frontend/src/lib/useSortState.dom.test.ts @@ -6,26 +6,19 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { renderHook, act } from "@testing-library/react"; -// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the -// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned. -const { store, ctl } = vi.hoisted(() => ({ - store: new Map(), - ctl: { account: 1 as number | null }, -})); +const { store } = vi.hoisted(() => ({ store: new Map() })); vi.mock("./storage", () => ({ - getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)), + getAccountRaw: (k: string) => store.get(k) ?? null, setAccountRaw: (k: string, v: string) => { - if (ctl.account != null) store.set(k, v); + store.set(k, v); }, })); -vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account })); import { useSortState } from "./useSortState"; describe("useSortState", () => { beforeEach(() => { store.clear(); - ctl.account = 1; }); it("hydrates to the fallback when storage is empty", () => { @@ -62,29 +55,4 @@ describe("useSortState", () => { const { result } = renderHook(() => useSortState("k")); expect(result.current[0]).toEqual({ key: "anything", dir: "desc" }); }); - - // The account-pin re-hydrate (via useAccountRehydrate) integrated into useSortState. - it("re-hydrates the stored sort once the account is pinned after mount", () => { - ctl.account = null; // fresh tab: no account at mount - store.set("k", JSON.stringify({ key: "views", dir: "desc" })); - const { result, rerender } = renderHook(() => useSortState("k")); - expect(result.current[0]).toBeNull(); // mount read with no account → default - act(() => { - ctl.account = 1; // 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" - }); }); diff --git a/frontend/src/lib/useSortState.ts b/frontend/src/lib/useSortState.ts index b9ad1c66..332b26f3 100644 --- a/frontend/src/lib/useSortState.ts +++ b/frontend/src/lib/useSortState.ts @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; import { getAccountRaw, setAccountRaw } from "./storage"; -import { useAccountRehydrate } from "./useAccountRehydrate"; import { hydrateSort, type SortOption, type SortState } from "./columnSort"; // The per-account persisted sort state, hydrated + healed against the current options — the plumbing @@ -14,17 +13,12 @@ export function useSortState( const [sort, setSort] = useState(() => hydrateSort(getAccountRaw(storageBase), options, fallback), ); - // When the account is pinned after mount (first load), re-read the stored sort for that account. - const markDirty = useAccountRehydrate(() => - setSort(hydrateSort(getAccountRaw(storageBase), options, fallback)), - ); const set = useCallback( (next: SortState) => { - markDirty(); setSort(next); setAccountRaw(storageBase, JSON.stringify(next)); }, - [storageBase, markDirty], + [storageBase], ); return [sort, set]; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 925ec233..7a5cecae 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -5,6 +5,7 @@ import { createQueryClient } from "./lib/queryClient"; import { FloatingTree } from "@floating-ui/react"; import ErrorBoundary from "./components/ErrorBoundary"; import { ConfirmProvider } from "./components/ConfirmProvider"; +import BootGate, { BootScreen } from "./components/BootGate"; import { NavigationProvider } from "./components/NavigationProvider"; import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import { SavedViewsProvider } from "./components/SavedViewsProvider"; @@ -24,7 +25,8 @@ import "flag-icons/css/flag-icons.min.css"; // The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers // can fetch them directly; the watch page is a login-free public player. Reached by full-page // navigation (plain ), so a simple pathname switch is enough — no router needed. -const App = lazy(() => import("./App")); +const loadApp = () => import("./App"); +const App = lazy(loadApp); const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy")); const Terms = lazy(() => import("./components/legal/Terms")); const WatchPage = lazy(() => import("./components/WatchPage")); @@ -39,6 +41,10 @@ de.theme ||= "dark"; de.scheme ||= "midnight"; const path = window.location.pathname; +const isPublicPage = path === "/privacy" || path === "/terms" || path.startsWith("/watch/"); +// Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens: +// the two waits then overlap rather than queue, and the gate usually opens straight onto the app. +if (!isPublicPage) void loadApp(); const root = path === "/privacy" ? ( @@ -56,29 +62,37 @@ const root = outside-press act only on the topmost layer (S3 folds the Modal + players in). */} - - - - - - {/* SavedViewsProvider sits below BOTH filter providers so it can switch its + {/* Nothing below renders until the tab knows which account it is: every provider + seeds per-account state in its initializer (see BootGate). */} + + + + + + + {/* SavedViewsProvider sits below BOTH filter providers so it can switch its source (feed ⟷ plex) by the active page and read the right live filters. */} - - - - - - - - - - - - - - - - + + + + + + {/* The same quiet screen as the gate's, so a chunk that is still + loading when the gate opens continues it instead of blanking. */} + }> + + + + + + + + + + + + +