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:
2026-09-15 00:53:15 +02:00
parent 4ad7183102
commit e795d191c5
13 changed files with 396 additions and 100 deletions
+84 -1
View File
@@ -9,20 +9,26 @@
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import BootGate from "./BootGate";
import { NavigationProvider, useNavigation } from "./NavigationProvider";
import { api, type Me } from "../lib/api";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import i18n from "../i18n";
const renders: { page: string; lang: string }[] = [];
let mounts = 0;
function Probe() {
const { page } = useNavigation();
const { i18n: inst } = useTranslation();
renders.push({ page, lang: inst.language });
useEffect(() => {
mounts++;
}, []);
return <div data-testid="app">{page}</div>;
}
@@ -37,20 +43,24 @@ function mount() {
</BootGate>
</QueryClientProvider>,
);
return qc;
}
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
beforeEach(async () => {
localStorage.clear();
sessionStorage.clear(); // a NEW tab: nothing pinned yet
window.history.replaceState(null, "", "/");
renders.length = 0;
mounts = 0;
await i18n.changeLanguage("en");
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("BootGate", () => {
@@ -90,4 +100,77 @@ describe("BootGate", () => {
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
});
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
vi.mocked(api.setupStatus).mockResolvedValue({ configured: false });
const me = vi.spyOn(api, "me").mockResolvedValue(account());
mount();
await screen.findByTestId("app");
expect(me).not.toHaveBeenCalled();
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
});
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
vi.mocked(api.setupStatus)
.mockRejectedValueOnce(new Error("502"))
.mockResolvedValue({ configured: true });
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(renders.length).toBeGreaterThan(0);
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account
});
it("an unanswered first account probe is an error with a retry", async () => {
vi.spyOn(api, "me").mockRejectedValueOnce(new Error("502")).mockResolvedValue(account());
mount();
fireEvent.click(await retryButton());
await screen.findByTestId("app");
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
it("a background refetch that fails keeps the app mounted, as it was", async () => {
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
me.mockRejectedValue(new Error("502"));
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
// react-query hands the error to its observers on a timer: let that render land before asserting.
await act(() => new Promise((r) => setTimeout(r, 20)));
expect(qc.getQueryState(qk.me())?.status).toBe("error");
expect(screen.getByTestId("app")).toBeTruthy();
expect(mounts).toBe(1);
});
it("a pin naming another account than the server answered as is corrected before the app reads it", async () => {
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
localStorage.setItem("siftlode.startOnHome.5", "0");
localStorage.setItem("siftlode.page.5", "downloads");
vi.spyOn(api, "me").mockResolvedValue(account());
mount();
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
});
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
const me = vi.spyOn(api, "me").mockResolvedValue(account());
const qc = mount();
await screen.findByTestId("app");
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
me.mockResolvedValue(null); // the session ended
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
await waitFor(() => expect(reload).toHaveBeenCalled());
});
});
+45 -34
View File
@@ -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}</>;
}
+3 -3
View File
@@ -7,9 +7,9 @@ import { usePref } from "../lib/usePref";
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
//
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and is
// adopted from server prefs on login. usePref carries that server-sync (plus the localStorage mirror
// that seeds the first render synchronously) so this provider is just the two contexts over it.
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick and follows
// the account across devices. usePref carries that server-sync (seeded from the server pref, with the
// localStorage mirror as the fallback) so this provider is just the two contexts over it.
interface FeedViewActions {
setView: (v: FeedView) => void;
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
//
// A shared Plex link is persisted to the account, but only the dimensions it CARRIES. The bug it
// replaced: a link with only a scope also wrote the (empty) default filters, and a stored Plex filters
// value at all is what tells SavedViewsProvider the account has picked filters — so the account's
// default saved view was never applied again.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NavigationProvider } from "./NavigationProvider";
import { PlexProvider } from "./PlexProvider";
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
function mount() {
render(
<QueryClientProvider client={new QueryClient()}>
<NavigationProvider>
<PlexProvider>
<div />
</PlexProvider>
</NavigationProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
localStorage.clear();
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
});
afterEach(() => {
cleanup();
window.history.replaceState(null, "", "/");
});
describe("PlexProvider shared link", () => {
it("persists the scope a link carries and leaves the dimensions it omits unwritten", () => {
window.history.replaceState(null, "", "/?page=plex&pscope=movie");
mount();
expect(localStorage.getItem("siftlode.plexLibrary.5")).toBe("movie");
expect(localStorage.getItem("siftlode.plexFilters.5")).toBeNull();
expect(localStorage.getItem("siftlode.plexSort.5")).toBeNull();
expect(localStorage.getItem("siftlode.plexShow.5")).toBeNull();
});
});
+21 -8
View File
@@ -151,15 +151,25 @@ function readPlexFilters(): PlexFilters {
}
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
function readPlexUrlSeed(): PlexUrlState | null {
// `present` records which dimensions the link actually carried: only those are persisted (below).
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
type PlexUrlSeed = PlexUrlState & { present: PlexSeedPresent };
function readPlexUrlSeed(): PlexUrlSeed | null {
const params = new URLSearchParams(window.location.search);
if (!hasPlexParams(params)) return null;
return plexParamsToState(params, {
const state = plexParamsToState(params, {
scope: readPlexScope(),
sort: readPlexSort(),
show: readPlexShow(),
filters: readPlexFilters(),
});
const present = {
scope: params.has("pscope"),
sort: params.has("psort"),
show: params.has("pshow"),
filters: params.has("pf"),
};
return { ...state, present };
}
export function PlexProvider({ children }: { children: ReactNode }) {
@@ -195,14 +205,17 @@ export function PlexProvider({ children }: { children: ReactNode }) {
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
// later plain reload. The seed's base was the stored view, so a dimension the link omitted is written
// back unchanged. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
// account has picked filters, so the account's default saved view would never be applied again.
useEffect(() => {
if (!urlSeed) return;
setAccountRaw(LS.plexLibrary, urlSeed.scope);
setAccountRaw(LS.plexShow, urlSeed.show);
setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
const { present } = urlSeed;
if (present.scope) setAccountRaw(LS.plexLibrary, urlSeed.scope);
if (present.show) setAccountRaw(LS.plexShow, urlSeed.show);
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
}, [urlSeed]);
const setScope = useCallback((v: string) => {
+5 -5
View File
@@ -28,8 +28,8 @@ const PERF_KEY = LS.perfMode;
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
// component) plus the last-saved "baseline" used to suppress the echo when a change came from adopting
// server prefs on login. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs().
// component) plus the last-saved "baseline" (the server values it was seeded with, then each successful
// save) used to suppress saving what the server already holds. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs().
type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
@@ -102,8 +102,8 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
useEffect(() => configureNotifications(notif), [notif]);
// Auto-save on change (debounced ~500ms so a slider drag coalesces into one PUT). Live-apply is the
// effects above; this is the only persistence path. `baselineRef` suppresses the echo when the
// change came from adopting server prefs on login (`/me` merges, so a full payload is fine).
// effects above; this is the only persistence path. `baselineRef` keeps the mount seed from being
// saved straight back (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
[theme, perf, hints, notif],
@@ -111,7 +111,7 @@ export function PrefsProvider({ children }: { children: ReactNode }) {
useEffect(() => {
window.clearTimeout(saveTimer.current);
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
// Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to
// Back at the last-saved value (the mount seed, or an edit undone) — nothing to
// save; clear any "Saving…" left from a change that was reverted before it flushed.
setSaveState((s) => (s === "saving" ? "idle" : s));
return;
+29 -33
View File
@@ -3,7 +3,7 @@ import { Download, File as FileIcon, Link2, Lock, PlayCircle, Repeat } from "luc
import { Button } from "./ui/Button";
import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { LS } from "../lib/storage";
import { LS, readJSON, writeJSON } from "../lib/storage";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@@ -137,21 +137,11 @@ export default function WatchPage() {
// Repeat the shared media when it ends — on unless the viewer turned it off. Remembered per browser
// (this page has no account); an unreadable storage just means the default.
const [repeat, setRepeat] = useState(() => {
try {
return localStorage.getItem(LS.watchRepeat) !== "0";
} catch {
return true;
}
});
const [repeat, setRepeat] = useState(() => readJSON<boolean>(LS.watchRepeat, true) !== false);
const toggleRepeat = () => {
const next = !repeat;
setRepeat(next);
try {
localStorage.setItem(LS.watchRepeat, next ? "1" : "0");
} catch {
/* storage unavailable — the toggle still applies to this visit */
}
writeJSON(LS.watchRepeat, next);
};
useEffect(() => {
@@ -310,9 +300,13 @@ export default function WatchPage() {
/>
</div>
)}
<div className="mt-3 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
{/* The actions wrap under the title when the row is too narrow for both (a phone), rather
than squeezing the title into a column a word wide. */}
<div className="mt-3 flex flex-wrap items-start gap-x-3 gap-y-2">
<div className="min-w-0 flex-1 basis-60">
<h1 className="text-lg font-semibold leading-snug break-words">
{meta.title || "Video"}
</h1>
{meta.uploader &&
(meta.uploader_url ? (
<a
@@ -327,24 +321,26 @@ export default function WatchPage() {
<div className="text-sm text-muted">{meta.uploader}</div>
))}
</div>
<Button
variant={repeat ? "outline-accent" : "outline"}
size="md"
icon={<Repeat className="w-4 h-4" />}
aria-pressed={repeat}
onClick={toggleRepeat}
className="shrink-0"
>
{T.repeat}
</Button>
{meta.allow_download && (
<a
href={meta.file_url}
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-3 py-2 text-sm font-medium transition"
<div className="flex shrink-0 items-center gap-2">
{/* An on/off toggle, so the shared pressed-pill paint (a solid fill when on) — not a
Button, whose variants differ only in colour. */}
<button
type="button"
aria-pressed={repeat}
onClick={toggleRepeat}
className="pill inline-flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium"
>
<Download className="w-4 h-4" /> {T.download}
</a>
)}
<Repeat className="w-4 h-4" aria-hidden /> {T.repeat}
</button>
{meta.allow_download && (
<a
href={meta.file_url}
className="inline-flex items-center gap-1.5 rounded-lg bg-surface hover:bg-card px-3 py-2 text-sm font-medium transition"
>
<Download className="w-4 h-4" /> {T.download}
</a>
)}
</div>
</div>
{(meta.source_url || (meta.extra_links && meta.extra_links.length > 0)) && (
<div className="mt-3 flex flex-col gap-1.5">
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
//
// A loading state fades in only after a delay, and loading states that hand over to each other share
// ONE clock. The bug it replaced: every LoadingState restarted its own 300 ms CSS delay, so a slow
// first load went spinner → blank → spinner when the page chunk's Suspense fallback handed over to
// the page's own "fetching" state. The clock is module state, so each test runs at its own time.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { LoadingState } from "./QueryState";
let now = 0;
beforeEach(() => {
vi.spyOn(performance, "now").mockImplementation(() => now);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
/** The reveal delay the rendered loading state waits, in ms (0 = shown at once). */
function revealDelay(container: HTMLElement): number {
const el = container.querySelector<HTMLElement>("[role=status]")!;
const m = /ease (\d+)ms/.exec(el.style.animation);
return m ? Number(m[1]) : 0;
}
describe("LoadingState reveal", () => {
it("a loading state on its own waits the full delay", () => {
now = 10_000;
const { container } = render(<LoadingState />);
expect(revealDelay(container)).toBe(300);
});
it("one that takes over from a loading state on screen keeps that one's clock", () => {
now = 20_000;
const { container, rerender } = render(<LoadingState key="fallback" />);
now = 20_200;
rerender(<LoadingState key="page" />); // a new element: the fallback leaves as it mounts
expect(revealDelay(container)).toBe(100);
});
it("once the shared clock has run out, the next one in the hand-off shows at once", () => {
now = 30_000;
const { container, rerender } = render(<LoadingState key="fallback" />);
now = 30_400;
rerender(<LoadingState key="page" />);
expect(revealDelay(container)).toBe(0);
});
it("a loading state after a pause starts a new clock", () => {
now = 40_000;
const first = render(<LoadingState />);
first.unmount();
now = 40_500;
const { container } = render(<LoadingState />);
expect(revealDelay(container)).toBe(300);
});
it("`since` counts the wait from an earlier start (the boot screen: navigation start)", () => {
now = 50_250;
const { container } = render(<LoadingState since={50_000} delayMs={400} />);
expect(revealDelay(container)).toBe(150);
});
});
+41 -8
View File
@@ -1,22 +1,55 @@
import type { ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Loader2 } from "lucide-react";
import { Button } from "./Button";
// A loading state fades in only after a delay: most loads here answer in tens of milliseconds, and a
// spinner shown for two frames before the content replaces it reads as a flicker, not as feedback. A
// load that really takes a while still gets the spinner, a third of a second in.
//
// The delay is ONE clock across loading states that hand over to each other, not a per-element CSS
// delay. One wait is often several elements in a row — the boot screen, then the page chunk's
// Suspense fallback, then the page's own "fetching" state — and a delay restarted by each would blank
// an already-visible spinner for another 300 ms at every hand-off. A loading state that mounts while
// another is still on screen (or has just left it) continues that one's clock instead.
const LOADING_REVEAL_MS = 300;
const HANDOFF_MS = 100;
const reveal = { live: 0, at: 0, lastGone: -Infinity };
function revealWait(since: number | undefined, delayMs: number): number {
const now = performance.now();
if (reveal.live === 0 && now - reveal.lastGone > HANDOFF_MS) reveal.at = (since ?? now) + delayMs;
return reveal.at - now;
}
// Centered loading state: a spinning accent ring + an optional label. The single shared loading
// visual for a page/section's first load, so all of them read the same (and can be swapped for
// skeletons later in one place). `role=status`/`aria-live` announces it to screen readers.
//
const LOADING_REVEAL_DELAY = "300ms";
// It fades in only after LOADING_REVEAL_DELAY: most first loads here answer in tens of milliseconds,
// and a spinner shown for two frames before the content replaces it reads as a flicker, not as
// feedback. A load that really takes a while still gets the spinner, a third of a second in.
export function LoadingState({ label, className = "" }: { label?: ReactNode; className?: string }) {
export function LoadingState({
label,
className = "",
since,
delayMs = LOADING_REVEAL_MS,
}: {
label?: ReactNode;
className?: string;
/** When the wait began (a `performance.now()` time), if earlier than this mount — the boot screen
* passes 0, navigation start. Only used when this state starts a new clock. */
since?: number;
delayMs?: number;
}) {
const { t } = useTranslation();
const [wait] = useState(() => revealWait(since, delayMs));
useEffect(() => {
reveal.live++;
return () => {
reveal.live--;
reveal.lastGone = performance.now();
};
}, []);
return (
<div
className={`flex flex-col items-center gap-3 py-10 text-sm text-muted ${className}`}
style={{ animation: `fadeIn 0.2s ease ${LOADING_REVEAL_DELAY} both` }}
style={wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined}
role="status"
aria-live="polite"
>
+1 -1
View File
@@ -65,7 +65,7 @@ export const LS = {
notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed",
seenVersion: "siftlode.seenVersion",
// The public /watch page's repeat toggle ("0" = off; on by default). Browser-wide, NOT per account:
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
// that page runs outside the app, with no account at all.
watchRepeat: "siftlode.watchRepeat",
chatDock: (userId: number) => `siftlode.chatDock.${userId}`,
+7 -7
View File
@@ -20,8 +20,8 @@ const reportPrefSaveFailure = (err: unknown) =>
// server-synced pref is a one-liner.
//
// It seeds straight from the `me` cache, which is always populated when it mounts: BootGate mounts the
// app tree only once /api/me has answered, keyed on the account, so there is nothing to adopt after
// mount. The source of truth is the server; the `me` cache is kept in sync optimistically so other
// app tree only once /api/me has answered, and a tab whose account changes reloads, so there is nothing
// to adopt after mount. The source of truth is the server; the `me` cache is kept in sync optimistically so other
// consumers reading the same key (e.g. a second player instance) see the change at once.
export interface UsePrefOptions<T> {
@@ -75,8 +75,8 @@ export function usePref<T>(
const { localKey, debounceMs, parse } = options;
const qc = useQueryClient();
// Latest parse in a ref so the seed/adopt/setter don't depend on a fresh closure each render (which
// would churn the memoized setter and the adopt effect). parse must be idempotent anyway.
// Latest parse in a ref so the seed and the setter don't depend on a fresh closure each render (which
// would churn the memoized setter). parse must be idempotent anyway.
const parseRef = useRef(parse);
parseRef.current = parse;
const norm = useCallback((raw: unknown): T => {
@@ -98,7 +98,7 @@ export function usePref<T>(
const saveTimer = useRef<number | undefined>(undefined);
// The pending debounced server write, if any — flushed (NOT dropped) on unmount so the last change
// isn't lost. Dropping it would leave the server stale while local/cache/localStorage hold the new
// value, and a later adopt could then overwrite the newer local value with the stale server one.
// value, and the next load — which seeds from the server first — would bring the old value back.
const pendingSave = useRef<(() => void) | null>(null);
useEffect(
() => () => {
@@ -113,8 +113,8 @@ export function usePref<T>(
(next: T) => {
setValue(next);
if (localKey) writeAccount(localKey, next);
// Keep the `me` cache in step so other readers of this key (and this hook's own adopt effect)
// see the new value without a refetch. Merges into preferences, mirroring the server PUT.
// Keep the `me` cache in step so other readers of this key (and a later mount of this hook,
// which seeds from the cache) see the new value without a refetch. Merges into preferences, mirroring the server PUT.
const payload = { [serverKey]: next };
mergePrefsCache(qc, payload);
// savePrefs is fire-and-forget (idempotent PUT, so `req` retries a transient blip once); a