import { useLayoutEffect, useRef, 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 { LoadingState, 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 per-account initializers read the tab's PIN (sessionStorage), not `me`, so the pin is made to // match the account the server answered as before they run. And the tree is only ever built for one // identity: if a mounted tab's identity changes without a reload (its session ended, or the account it // was pinned to was signed out in another tab and the server now answers as the browser's default), // the tab reloads, like every account switch does (NavSidebar) — which also drops the previous // account's query cache, whose keys are not per account. /** The quiet screen shown while the tab's identity (or the app chunk) is still on its way — the shared * loading visual, full screen. Its reveal counts from navigation start, so the gate's screen, the * Suspense fallback after it and the first page's own loading state keep one clock (see LoadingState). */ export function BootScreen() { return (
); } 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 ended session is noticed with no interaction: /api/me answers a // signed-out (or suspended) caller with 200 and no account, the identity drops to "nobody", and the // tab reloads 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, or setup mode — App // renders the landing / the wizard for those), `undefined` while unknown or unanswerable. // 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.data ? undefined : 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 the server answered 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. The server honours // that header only for an account still signed in to this browser, so a pin naming one that is // not is corrected here — otherwise the tab would run as the default account on the server while // reading and writing the gone account's local state. // • 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); // `me` is read for the identity it produced, so it rides a ref: a background refetch of the same // account must not re-run the adoption over a language the user has just changed. That rule used // to live in a dep list that excluded `me`, which made the dep list load-bearing for correctness — // and the failure mode of getting it wrong was a page-reload loop, not a stale value. const meRef = useRef(me); meRef.current = me; useLayoutEffect(() => { if (identity === undefined) return; if (readyFor !== undefined) { // Already adopted. Only a DIFFERENT identity reloads: the tree was built for another account — // see the header. The pin stays as it is: the tree being torn down still runs its unmount saves // before the page goes, and with the pin moved they would land under the new account's local // keys. The reloaded page's first probe corrects it. Comparing rather than merely testing for // "set" is what makes this effect idempotent, so a re-run for any other reason is a no-op // instead of a reload. if (readyFor !== identity) location.reload(); return; } const account = meRef.current; if (account && getActiveAccount() !== account.id) setActiveAccount(account.id); const lang = account?.preferences?.language; if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang); // eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them. setReadyFor(identity); }, [identity, readyFor]); // No answer at all (a network or 5xx failure on the very first setup or account probe — past the // one retry `req` already makes): say so, with a retry. An unanswered setup probe is NOT "signed // out": rendering the login page to a signed-in user would be a lie. After a retry the identity // lands and the tree mounts from scratch, like any first load. const failed = !setupQuery.data && setupQuery.isError ? setupQuery : me === undefined && meQuery.isError ? meQuery : null; if (failed) return (
failed.refetch()}> {t("common.somethingWrong")}
); if (identity === undefined || readyFor !== identity) return ; return <>{children}; }