fix(boot): review round 1 — reconcile the tab pin, one loading clock, honest probe failures
Deep-review round 1 over main..improvement/watch-background-audio. BootGate - The tab's account pin is corrected to the account the server answered as. The server honours the pin only for an account still signed in to this browser and otherwise answers as the default, so a tab pinned to an account signed out in another tab ran as the default on the server while reading and writing the gone account's local state. - A mounted tab whose identity changes (its session ended, or the case above) reloads instead of rebuilding the tree in place, like every account switch; that also drops the previous account's query cache. The account key on the tree goes: the readyFor gate already unmounted it on every change. - An unanswered setup probe shows the error with Retry, like an unanswered account probe, instead of the signed-out landing to a signed-in user. - Comments: /api/me never 401s; the ended-session path is identity → nobody. Loading states - LoadingState's reveal delay is one clock across loading states that hand over to each other (boot screen → page-chunk fallback → the page's own fetching state), instead of a CSS delay restarted by each, which blanked a visible spinner for another 300 ms at every hand-off. BootScreen is now the shared loading visual, full screen, counting from navigation start. Plex shared link - Persists only the dimensions the link carries again. Writing the default filters for an omitted `pf` created the stored-filters key, which is what tells SavedViewsProvider the account picked filters, so the account's default Plex saved view was never applied again. Watch page - The Repeat toggle uses the shared pressed-pill paint (a solid fill when on) instead of Button variants that differ only in colour, and its flag goes through storage.ts readJSON/writeJSON. - The actions wrap under the title when the row is too narrow, instead of squeezing the title into a column a word wide on a phone; the title breaks long words. Comments that still justified code by the removed adopt effects (usePref, PrefsProvider, FeedViewProvider) are rewritten against the seed-once model. Tests: BootGate error/retry for both probes, setup mode, a failed background refetch keeping the app, the pin correction and the reload on an identity change; the shared reveal clock; the Plex link persist; the audio signature actually moved off version 4; a video download (av and v) still remuxed to an mp4. Each mutation-checked RED for its own reason.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { Fragment, useLayoutEffect, useState, type ReactNode } from "react";
|
||||
import { 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";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
|
||||
// The boot sequence, in ONE place: nothing of the app renders until this tab knows WHO it is.
|
||||
//
|
||||
@@ -18,27 +18,23 @@ import { StateMessage } from "./ui/QueryState";
|
||||
// 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 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. 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. */
|
||||
/** 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() {
|
||||
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 className="min-h-screen grid place-items-center">
|
||||
<LoadingState since={0} delayMs={400} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const LABEL_DELAY_MS = 400;
|
||||
|
||||
export default function BootGate({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -52,9 +48,10 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
});
|
||||
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.
|
||||
// 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,
|
||||
@@ -63,13 +60,13 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
});
|
||||
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.
|
||||
// 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.isPending
|
||||
const identity: number | null | undefined = !setupQuery.data
|
||||
? undefined
|
||||
: !setupQuery.isSuccess || needsSetup
|
||||
: needsSetup
|
||||
? null
|
||||
: me === undefined
|
||||
? undefined
|
||||
@@ -77,16 +74,22 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
|
||||
// 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.
|
||||
// • 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);
|
||||
useLayoutEffect(() => {
|
||||
if (identity === undefined) return;
|
||||
if (me && getActiveAccount() == null) setActiveAccount(me.id);
|
||||
if (me && getActiveAccount() !== me.id) setActiveAccount(me.id);
|
||||
if (readyFor !== undefined) {
|
||||
location.reload(); // the tree was built for another identity — see the header
|
||||
return;
|
||||
}
|
||||
const lang = me?.preferences?.language;
|
||||
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
|
||||
setReadyFor(identity);
|
||||
@@ -95,16 +98,24 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
// 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)
|
||||
// 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-screen grid place-items-center">
|
||||
<StateMessage tone="error" onRetry={() => meQuery.refetch()}>
|
||||
<StateMessage tone="error" onRetry={() => failed.refetch()}>
|
||||
{t("common.somethingWrong")}
|
||||
</StateMessage>
|
||||
</div>
|
||||
);
|
||||
if (identity === undefined || readyFor !== identity) return <BootScreen />;
|
||||
return <Fragment key={identity ?? "anon"}>{children}</Fragment>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user