With Settings -> Appearance -> Startup turned off, every NEW tab or window still opened on Home; only a same-tab reload honoured it. The provider tree mounted before /api/me answered, so NavigationProvider's initializer could not read the per-account opt-out, landed on Home and mirrored ?page=home into the address bar. When App pinned the account a moment later, the re-hydrate re-read that mirror and stayed on Home. The same mount-then-patch shape made a new tab start on default filters, the default theme and the browser language, and correct each after the first paint. BootGate (main.tsx, above the providers) now owns the setup-status and /api/me queries and mounts the tree only once the identity is settled: it pins the tab's account and adopts the account's language in a layout effect, so the first frame of the app is already right. The tree is keyed on the account id, which makes "per-account state is read once, for this account" a structural guarantee. With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the reactive active-account store, the markDirty guards, the pin-deferred saved-view seed and the present-dimension bookkeeping of the shared Channels/Plex links. PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the me cache instead of adopting it in an effect; the theme is applied in layout effects. The app chunk is fetched in parallel with the probe, and the boot screen only fades its label in after 400 ms, on one clock across the gate and the Suspense fallback. Module switches no longer flash a placeholder either. Measured frame by frame in Chromium and WebKit, every first visit to a module showed "Loading..." for 17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's LoadingState. Pages are now lazyWithPreload components, and App fetches the chunks of every module the account can reach (plus the channel page) once the first page is idle; a preloaded page renders synchronously and never suspends. LoadingState, the one shared loading visual, fades in only after 300 ms, so a fast load shows nothing in between and a slow one still gets its spinner.
111 lines
6.1 KiB
TypeScript
111 lines
6.1 KiB
TypeScript
import { Fragment, useLayoutEffect, useState, type ReactNode } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useTranslation } from "react-i18next";
|
|
import { api, getActiveAccount, setActiveAccount } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import i18n, { isSupported, setLanguage } from "../i18n";
|
|
import { StateMessage } from "./ui/QueryState";
|
|
|
|
// The boot sequence, in ONE place: nothing of the app renders until this tab knows WHO it is.
|
|
//
|
|
// Per-account state is everywhere — the landing page, the feed's filters, the Plex scope, the manager's
|
|
// tab, the theme, the language — and all of it is read synchronously when its provider mounts. Those
|
|
// providers used to mount before /api/me answered, so a new tab first built the app for "no account"
|
|
// (defaults: Home instead of the page you left, the default theme, the browser language) and then
|
|
// patched each piece once <App> pinned the account. Every patch was a visible jump at best, and at
|
|
// worst it read a value the first pass had already written (the landing page mirrored ?page=home into
|
|
// the address bar, then re-read its own mirror and stayed on Home). So the order is inverted instead:
|
|
// settle the identity, adopt what must be adopted before a single pixel of the app is drawn, THEN mount
|
|
// the provider tree — whose initializers are then simply right, with nothing to re-read.
|
|
//
|
|
// The tree is keyed on the account id, which enforces that in code rather than in a comment: if a tab's
|
|
// identity ever changed without a reload, every per-account initializer would run again for the new
|
|
// account instead of holding the previous one's values. (Today every switch reloads — NavSidebar.)
|
|
|
|
/** The quiet screen shown while the tab's identity (or the app chunk) is still on its way. On a normal
|
|
* connection that is a few hundred milliseconds, and a "Loading…" label flashed for that long reads as
|
|
* a flicker, not as information — so the label only fades in once the wait is long enough to need one.
|
|
* The delay counts from navigation start, not from mount, so the gate's screen and the Suspense
|
|
* fallback that may follow it keep one clock instead of each restarting the fade. */
|
|
export function BootScreen() {
|
|
const { t } = useTranslation();
|
|
const [wait] = useState(() => LABEL_DELAY_MS - performance.now());
|
|
return (
|
|
<div className="min-h-screen grid place-items-center text-muted">
|
|
<span style={wait > 0 ? { animation: `fadeIn 0.2s ease ${wait}ms both` } : undefined}>
|
|
{t("common.loading")}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
const LABEL_DELAY_MS = 400;
|
|
|
|
export default function BootGate({ children }: { children: ReactNode }) {
|
|
const { t } = useTranslation();
|
|
// First-run install gate: a fresh instance reports configured=false and runs in setup mode, where
|
|
// every other route is locked — so `me` is held back until the instance is known to be set up
|
|
// (otherwise it would 503 against the setup-mode lock).
|
|
const setupQuery = useQuery({
|
|
queryKey: qk.setupStatus(),
|
|
queryFn: api.setupStatus,
|
|
staleTime: Infinity,
|
|
});
|
|
const needsSetup = setupQuery.data?.configured === false;
|
|
// THE fetching `me` query; every other reader is a passive (enabled:false) observer of its cache.
|
|
// Polled while signed in so an account an admin suspends or deletes is noticed with no interaction:
|
|
// the refetch 401s and App's handler drops to the login page. Not polled signed out — that refetch
|
|
// is pointless and used to flicker the public landing.
|
|
const meQuery = useQuery({
|
|
queryKey: qk.me(),
|
|
queryFn: api.me,
|
|
enabled: setupQuery.isSuccess && !needsSetup,
|
|
refetchInterval: (query) => (query.state.data ? 60_000 : false),
|
|
});
|
|
const me = meQuery.data;
|
|
|
|
// Who this tab is, once known: an account id, `null` for "nobody" (signed out, setup mode, or no
|
|
// setup answer at all — App renders the landing / the wizard for those), `undefined` while unknown.
|
|
// Keyed on the DATA, not the status: a background refetch that fails keeps the last answer, so a
|
|
// server blip never tears the whole app down to this screen.
|
|
const identity: number | null | undefined = setupQuery.isPending
|
|
? undefined
|
|
: !setupQuery.isSuccess || needsSetup
|
|
? null
|
|
: me === undefined
|
|
? undefined
|
|
: (me?.id ?? null);
|
|
|
|
// Adopt the identity before the tree mounts. A layout effect, so the re-render it triggers happens
|
|
// before the browser paints: the frame that first shows the app already has the account pinned.
|
|
// • Pin this tab to the account it loaded as. Without the pin, a tab riding the session's default
|
|
// account would silently swap identity when ANOTHER tab changes that default (adds or switches
|
|
// an account); once pinned it sends its own account header. An explicit switch on THIS tab sets
|
|
// the pin itself, so this is a no-op then.
|
|
// • Adopt the account's language, which follows the account across devices — the local copy only
|
|
// knows what this browser last showed.
|
|
const [readyFor, setReadyFor] = useState<number | null | undefined>(undefined);
|
|
useLayoutEffect(() => {
|
|
if (identity === undefined) return;
|
|
if (me && getActiveAccount() == null) setActiveAccount(me.id);
|
|
const lang = me?.preferences?.language;
|
|
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
|
|
setReadyFor(identity);
|
|
// `me` is read for the identity it produced; a background refetch of the same account must not
|
|
// re-run the adoption over a language the user has just changed.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [identity]);
|
|
|
|
// No answer at all (a network or 5xx failure on the very first probe): say so, with a retry. After
|
|
// a retry the identity lands and the tree mounts from scratch, like any first load.
|
|
if (me === undefined && meQuery.isError)
|
|
return (
|
|
<div className="min-h-screen grid place-items-center">
|
|
<StateMessage tone="error" onRetry={() => meQuery.refetch()}>
|
|
{t("common.somethingWrong")}
|
|
</StateMessage>
|
|
</div>
|
|
);
|
|
if (identity === undefined || readyFor !== identity) return <BootScreen />;
|
|
return <Fragment key={identity ?? "anon"}>{children}</Fragment>;
|
|
}
|