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}; }