The table header carried an arbitrary-value copy of fade-below; a contract refuses a hand gradient of a surface colour. 100vh is the viewport with a tablet browser's toolbar retracted, so h-screen / min-h-screen / static vh hid the last rows under it; a contract holds dvh (the Plex player stage changes with S3f, where it is played on the tablet).
133 lines
7.7 KiB
TypeScript
133 lines
7.7 KiB
TypeScript
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 <App> pinned the account. Every patch was a visible jump at best, and at
|
|
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
|
|
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
|
|
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
|
|
// the provider tree — whose initializers are then simply right, with nothing to re-read.
|
|
//
|
|
// The 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 (
|
|
<div className="min-h-dvh grid place-items-center">
|
|
<LoadingState since={0} delayMs={400} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<number | null | undefined>(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 (
|
|
<div className="min-h-dvh grid place-items-center">
|
|
<StateMessage tone="error" onRetry={() => failed.refetch()}>
|
|
{t("common.somethingWrong")}
|
|
</StateMessage>
|
|
</div>
|
|
);
|
|
if (identity === undefined || readyFor !== identity) return <BootScreen />;
|
|
return <>{children}</>;
|
|
}
|