;
+};
+
+export function lazyWithPreload(
+ factory: () => Promise<{ default: ComponentType
}>,
+): PreloadableComponent
{
+ let loaded: ComponentType
| null = null;
+ let pending: Promise | null = null;
+ const preload = () =>
+ (pending ??= factory().then(
+ (m) => {
+ loaded = m.default;
+ },
+ (err: unknown) => {
+ pending = null;
+ throw err;
+ },
+ ));
+ const Lazy = lazy(() => preload().then(() => ({ default: loaded as ComponentType })));
+
+ function Preloadable(props: P) {
+ return createElement(loaded ?? (Lazy as unknown as ComponentType
), props);
+ }
+ return Object.assign(Preloadable, { preload });
+}
diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts
index b57cc7c3..7e33f3ca 100644
--- a/frontend/src/lib/notifications.ts
+++ b/frontend/src/lib/notifications.ts
@@ -2,6 +2,7 @@
// the Notification Center shows (info events, actions awaiting interaction, and app
// errors). History is persisted to localStorage so it survives reloads; action
// callbacks are live-only and dropped on reload.
+import { activeAccountId, onAccountPinned } from "./activeAccount";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
@@ -205,6 +206,22 @@ function emit() {
recompute();
+// The history and settings above are read at import, which in a new tab is before BootGate pins its
+// account — so they read as nobody's. Read them again for the account once it is pinned: otherwise the
+// tab's first write would replace the account's stored history with its own empty one. A notice raised
+// before the pin (a failed boot probe) belongs to no account, so it stays, after the history.
+let loadedFor = activeAccountId();
+onAccountPinned(() => {
+ const unpinned = loadedFor == null ? items : [];
+ loadedFor = activeAccountId();
+ config = loadSettings();
+ // Fresh ids for the history: the notices kept may already hold the low ones (a timer, a producer).
+ const history = load().map((n) => ({ ...n, id: counter++ }));
+ items = [...history, ...unpinned].slice(-MAX_HISTORY);
+ recompute();
+ listeners.forEach((l) => l());
+});
+
export function subscribe(listener: () => void): () => void {
listeners.push(listener);
return () => {
diff --git a/frontend/src/lib/panelLayout.dom.test.ts b/frontend/src/lib/panelLayout.dom.test.ts
new file mode 100644
index 00000000..1cb32892
--- /dev/null
+++ b/frontend/src/lib/panelLayout.dom.test.ts
@@ -0,0 +1,32 @@
+// @vitest-environment jsdom
+//
+// A panel's layout is seeded from the account's server prefs when the app mounts. The adopt effect this
+// replaced corrected it after mounting; with it gone, a seed that read only this browser's copy would
+// never show a layout changed on another device.
+import { beforeEach, describe, expect, it } from "vitest";
+import { seedLayout } from "./panelLayout";
+import { ACTIVE_ACCOUNT_KEY } from "./activeAccount";
+import { accountKey, LS } from "./storage";
+
+const local = { order: [], collapsed: { genre: true }, hidden: {} };
+
+beforeEach(() => {
+ localStorage.clear();
+ sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "5");
+ localStorage.setItem(accountKey(LS.plexLayout, 5)!, JSON.stringify(local));
+});
+
+describe("seedLayout", () => {
+ it("takes the account's server layout over this browser's copy", () => {
+ const l = seedLayout("plex", {
+ plexLayout: { order: [], collapsed: {}, hidden: { year: true } },
+ });
+ expect(l.hidden).toEqual({ year: true });
+ expect(l.collapsed).toEqual({});
+ });
+
+ it("uses this browser's copy for a layout the server has never stored", () => {
+ expect(seedLayout("plex", {}).collapsed).toEqual({ genre: true });
+ expect(seedLayout("plex", undefined).collapsed).toEqual({ genre: true });
+ });
+});
diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts
index f1f66c73..76161489 100644
--- a/frontend/src/lib/panelLayout.ts
+++ b/frontend/src/lib/panelLayout.ts
@@ -50,7 +50,7 @@ export function defaultLayout(panel: PanelId): PanelLayout {
return { order: [...PANEL_ORDER[panel]], collapsed: {}, hidden: {} };
}
-export function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout {
+function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout {
const all = PANEL_ORDER[panel];
const r = (raw ?? {}) as Partial;
const order = Array.isArray(r.order) ? r.order.filter((x) => all.includes(x)) : [];
@@ -62,10 +62,20 @@ export function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout {
};
}
-export function loadLayout(panel: PanelId): PanelLayout {
+function loadLayout(panel: PanelId): PanelLayout {
return normalizeLayout(panel, readAccount(LS_KEY[panel], {}));
}
+// A panel's layout when the app mounts: the account's server copy (the `me` cache's preferences) when
+// it has one — so a layout changed on another device is right on the first paint — else this browser's.
+export function seedLayout(
+ panel: PanelId,
+ prefs: Record | undefined,
+): PanelLayout {
+ const raw = prefs?.[PREF_KEY[panel]];
+ return raw ? normalizeLayout(panel, raw) : loadLayout(panel);
+}
+
export function saveLayoutLocal(panel: PanelId, l: PanelLayout): void {
writeAccount(LS_KEY[panel], l);
}
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index afa65b2e..af68b8c5 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,30 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.87.0",
+ date: "2026-09-15",
+ summary:
+ "A shared or library video keeps playing when you switch apps on an iPad, and a new tab opens as the account it belongs to without flashing another page first. Facebook reels keep their titles and an mp3 stays an mp3.",
+ features: [
+ "Background play on iPad (Brave): the shared-video page, the library player and the Plex player keep playing in another app and on the locked screen.",
+ "The shared-video page has a Repeat toggle, on by default.",
+ "Switching modules no longer flashes a loading screen: the pages load quietly in the background, and a slow answer shows its spinner only after a moment.",
+ ],
+ fixes: [
+ "A new tab honours your Startup choice and opens as its own account — no Home page flash, no other account's language or settings on the first frame.",
+ "A signed-in Facebook reel keeps its real title instead of “Video” or “Facebook”, and an mp3 download stays an mp3 (it used to be wrapped into an mp4 an iPad would not play).",
+ "A tab whose account was signed out, suspended or signed out everywhere acts as nobody and comes back as the account still signed in, instead of writing into another account or sticking on the sign-in page.",
+ "Switching or adding an account no longer saves the video you were watching into the other account's resume position.",
+ "A new tab keeps the account's notification history instead of replacing it with its own first notice.",
+ "The shared-video page tells a failed load, a wrong password, too many attempts and an unreachable server apart, with Retry where it helps. Its password limit counts per viewer, so one person guessing cannot lock everyone else out.",
+ "Form errors on sign-in, the message passphrase, password change and setup are read out by screen readers — again on a second identical failure — and the setup wizard names what is wrong instead of a generic error.",
+ ],
+ chores: [
+ "WebKit and iPad projects in the Playwright suite; the Vite dev proxy forwards websockets.",
+ "Four deep-review rounds (12 → 8 → 8 → 8, the last one targeted), every regression test mutation-checked; half of the later rounds' findings were older defects, fixed here.",
+ ],
+ },
{
version: "0.86.0",
date: "2026-09-14",
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index c260fb03..adc1b7b6 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { activeAccountId } from "./activeAccount";
-import { useAccountRehydrate } from "./useAccountRehydrate";
// Central registry of every localStorage key the app uses. One place to see them all, rename
// safely, and avoid collisions — instead of `"siftlode.x"` string literals scattered across
@@ -16,15 +15,13 @@ export const LS = {
filters: "siftlode.filters",
page: "siftlode.page",
// E24: start every session on the Home surface rather than on the module you happened to leave
- // open. Per-account, "0" = opt out and go back to remembering the last module. It has to be
- // localStorage rather than a server pref because `loadInitialPage` runs before /api/me lands —
- // a landing page decided after the first paint would be a visible jump.
+ // open. Per-account, "0" = opt out and go back to remembering the last module. Read by
+ // `loadInitialPage` in NavigationProvider's initializer, which BootGate runs only once the tab's
+ // account is pinned — so the landing is decided right before the first paint, never corrected after.
startOnHome: "siftlode.startOnHome",
perfMode: "siftlode.perfMode",
- // The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
- // established tab paints your view straight away. A brand-new tab still starts on the default
- // until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
- // the per-account key can't be read.
+ // The feed's view mode (see lib/feedView.ts) — a local copy of the server pref, which usePref seeds
+ // from only when the server has never stored the key.
feedView: "siftlode.feedView",
// The subscribed table's column filters (a FilterMap), lifted out of the table so the top status
// chips, the Sync column funnel, and the card layout all share ONE state (per-account). Supersedes
@@ -68,11 +65,14 @@ export const LS = {
notifSettings: "siftlode.notifSettings",
onboardingDismissed: "siftlode.onboarding.dismissed",
seenVersion: "siftlode.seenVersion",
+ // 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}`,
} as const;
// --- per-account scoping -----------------------------------------------------------------
-// The active-account primitive (activeAccountId / useActiveAccountId / notify) lives in ./activeAccount
+// The active-account primitive (activeAccountId) lives in ./activeAccount
// so it can be shared without a cycle; the helpers below key localStorage on it.
/** Suffix a base localStorage key with an account id so per-account UI state (filters, selected
@@ -154,24 +154,21 @@ export function writeJSON(key: string, value: unknown): void {
/** A `useState` whose string value is mirrored to localStorage, scoped to the current account (see
* accountKey), so one account's persisted tab/view position doesn't carry over to another in the
* same browser. Survives reload (F5); validation is deferred to the caller (clamp at render) so it
- * can be used before an async-loaded option list is known, keeping hook order stable. Consumers
- * usually mount only once signed in (account known), but re-read on the account-pin transition too so
- * one that mounts before the pin (a fresh tab's first paint) still restores. */
+ * can be used before an async-loaded option list is known, keeping hook order stable. Read once, at
+ * mount: BootGate mounts every consumer after the account is pinned. */
export function useAccountPersistedState(
base: string,
fallback = "",
): [string, (value: string) => void] {
const [value, setValue] = useState(() => getAccountRaw(base) ?? fallback);
- const markDirty = useAccountRehydrate(() => setValue(getAccountRaw(base) ?? fallback));
- // Stable setter (base + markDirty are both stable) so a provider can build a stable action on top
- // of it — e.g. ChannelsProvider's setView, which must not change identity on every tab switch.
+ // Stable setter (base is stable) so a provider can build a stable action on top of it — e.g.
+ // ChannelsProvider's setView, which must not change identity on every tab switch.
const set = useCallback(
(next: string) => {
- markDirty();
setValue(next);
setAccountRaw(base, next);
},
- [base, markDirty],
+ [base],
);
return [value, set];
}
@@ -184,17 +181,15 @@ export function useAccountPersistedObject(
defaults: T,
): [T, (patch: Partial) => void] {
const [value, setValue] = useState(() => readAccountMerged(base, defaults));
- const markDirty = useAccountRehydrate(() => setValue(readAccountMerged(base, defaults)));
const patch = useCallback(
(p: Partial) => {
- markDirty();
setValue((prev) => {
const next = { ...prev, ...p };
writeAccount(base, next);
return next;
});
},
- [base, markDirty],
+ [base],
);
return [value, patch];
}
diff --git a/frontend/src/lib/useAccountRehydrate.dom.test.ts b/frontend/src/lib/useAccountRehydrate.dom.test.ts
deleted file mode 100644
index 8ba5cacd..00000000
--- a/frontend/src/lib/useAccountRehydrate.dom.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-// @vitest-environment jsdom
-//
-// useAccountRehydrate re-reads per-account state when the tab's account is pinned after mount (first
-// load). useActiveAccountId is mocked with a plain variable so the transition can be driven by hand.
-import { describe, it, expect, beforeEach, vi } from "vitest";
-import { renderHook, act } from "@testing-library/react";
-
-let account: number | null = null;
-vi.mock("./activeAccount", () => ({
- useActiveAccountId: () => account,
-}));
-
-import { useAccountRehydrate } from "./useAccountRehydrate";
-
-describe("useAccountRehydrate", () => {
- beforeEach(() => {
- account = null;
- });
-
- it("runs rehydrate when the account transitions from null to a value", () => {
- const rehydrate = vi.fn();
- const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
- expect(rehydrate).not.toHaveBeenCalled(); // mount with no account: no transition
- account = 1;
- act(() => rerender());
- expect(rehydrate).toHaveBeenCalledTimes(1);
- });
-
- it("does not run when the account id is already known and stable", () => {
- account = 1;
- const rehydrate = vi.fn();
- const { rerender } = renderHook(() => useAccountRehydrate(rehydrate));
- act(() => rerender());
- expect(rehydrate).not.toHaveBeenCalled();
- });
-
- it("skips rehydrate when marked dirty before the account pins (no clobber of a user edit)", () => {
- const rehydrate = vi.fn();
- const { result, rerender } = renderHook(() => useAccountRehydrate(rehydrate));
- act(() => result.current()); // markDirty — the owning setter ran pre-pin
- account = 1;
- act(() => rerender());
- expect(rehydrate).not.toHaveBeenCalled();
- });
-});
diff --git a/frontend/src/lib/useAccountRehydrate.ts b/frontend/src/lib/useAccountRehydrate.ts
deleted file mode 100644
index 51f7cdac..00000000
--- a/frontend/src/lib/useAccountRehydrate.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import { useActiveAccountId } from "./activeAccount";
-
-// Re-read per-account persisted state when this tab's active account becomes known (or changes). The
-// provider tree above mounts before the account is pinned, so its useState initializers seed from
-// an unknown account (empty defaults); once pins it, `rehydrate` runs to load the real stored
-// value. Guarded so it never clobbers a value the user set in the brief pre-pin window: the returned
-// `markDirty` is called by the owning setter, and a dirty state skips the re-hydrate.
-//
-// Keyed on the account id ONLY — the user's own writes go through setAccountRaw, which does not change
-// the account id, so this effect can't loop on them.
-//
-// This is the localStorage-only counterpart of the `useEffect([me?.id])` server-prefs adopt pattern in
-// FeedFiltersProvider / FeedViewProvider / PrefsProvider (and App's shell prefs): those re-load from the
-// SERVER when the account query resolves; this re-loads from localStorage when the account is pinned.
-// A provider needs one or the other — not both — to survive a fresh tab's first paint.
-export function useAccountRehydrate(rehydrate: () => void): () => void {
- const accountId = useActiveAccountId();
- const dirty = useRef(false);
- const prev = useRef(accountId);
- // Latest closure in a ref so the effect depends on the account id alone (a fresh `rehydrate` each
- // render must not re-fire it), while still calling the current logic.
- const fn = useRef(rehydrate);
- fn.current = rehydrate;
-
- useEffect(() => {
- if (accountId === prev.current) return; // mount, and renders with no real transition
- prev.current = accountId;
- if (!dirty.current) fn.current();
- // Reset per account: `dirty` means "the user edited under THIS account". Today a real account
- // switch full-reloads (fresh instance), but clearing it here keeps the guard correct even if a
- // future switch stops reloading — the next account starts clean rather than inheriting dirtiness.
- dirty.current = false;
- }, [accountId]);
-
- return useCallback(() => {
- dirty.current = true;
- }, []);
-}
diff --git a/frontend/src/lib/useFilterState.dom.test.ts b/frontend/src/lib/useFilterState.dom.test.ts
index c6d5655a..44c36908 100644
--- a/frontend/src/lib/useFilterState.dom.test.ts
+++ b/frontend/src/lib/useFilterState.dom.test.ts
@@ -6,26 +6,19 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
-// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
-// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
-const { store, ctl } = vi.hoisted(() => ({
- store: new Map(),
- ctl: { account: 1 as number | null },
-}));
+const { store } = vi.hoisted(() => ({ store: new Map() }));
vi.mock("./storage", () => ({
- getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
+ getAccountRaw: (k: string) => store.get(k) ?? null,
setAccountRaw: (k: string, v: string) => {
- if (ctl.account != null) store.set(k, v);
+ store.set(k, v);
},
}));
-vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
import { useFilterState } from "./useFilterState";
describe("useFilterState", () => {
beforeEach(() => {
store.clear();
- ctl.account = 1;
});
it("hydrates to an empty map when storage is empty", () => {
@@ -77,42 +70,4 @@ describe("useFilterState", () => {
// A seed that returns null falls back to an empty map.
expect(renderHook(() => useFilterState("k3", () => null)).result.current[0]).toEqual({});
});
-
- // The account-pin re-hydrate (via useAccountRehydrate) integrated into useFilterState.
- it("re-hydrates the stored map once the account is pinned after mount", () => {
- ctl.account = null; // fresh tab: no account at mount
- store.set("k", JSON.stringify({ sync: "fully", tags: ["7"] }));
- const { result, rerender } = renderHook(() => useFilterState("k"));
- expect(result.current[0]).toEqual({}); // mount read with no account → empty
- act(() => {
- ctl.account = 1; // pins the account
- rerender();
- });
- expect(result.current[0]).toEqual({ sync: "fully", tags: ["7"] });
- });
-
- it("applies the migrate seed on the account-pin re-hydrate when the key is still empty", () => {
- ctl.account = null;
- // Mirrors migrateSyncChip: reads an account-scoped legacy key, so it yields null until pinned.
- const migrate = () => (ctl.account == null ? null : { sync: "needsFull" });
- const { result, rerender } = renderHook(() => useFilterState("k", migrate));
- expect(result.current[0]).toEqual({}); // migrate can't read the legacy key with no account yet
- act(() => {
- ctl.account = 1;
- rerender();
- });
- expect(result.current[0]).toEqual({ sync: "needsFull" }); // migrate carries the legacy value forward
- });
-
- it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
- ctl.account = null;
- store.set("k", JSON.stringify({ sync: "fully" })); // the stored value
- const { result, rerender } = renderHook(() => useFilterState("k"));
- act(() => result.current[1]({ sync: "needsFull" })); // user edits before the pin
- act(() => {
- ctl.account = 1;
- rerender();
- });
- expect(result.current[0]).toEqual({ sync: "needsFull" }); // the edit survives, not "fully"
- });
});
diff --git a/frontend/src/lib/useFilterState.ts b/frontend/src/lib/useFilterState.ts
index 29ffd1d6..e2171181 100644
--- a/frontend/src/lib/useFilterState.ts
+++ b/frontend/src/lib/useFilterState.ts
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { getAccountRaw, setAccountRaw } from "./storage";
-import { useAccountRehydrate } from "./useAccountRehydrate";
import type { FilterMap } from "./tableColumns";
// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the
@@ -14,22 +13,17 @@ export function useFilterState(
// flash); it stays idempotent because the seed persists as soon as the user touches the filters.
migrate?: () => FilterMap | null,
): [FilterMap, (next: FilterMap) => void] {
- // Read the account's stored map, or the migrate seed when its key is still empty. Shared by the
- // initial mount AND the account-pin re-hydrate, so first-load migration runs on whichever fires with
- // the account known (on a fresh tab the account is null at mount → the re-hydrate does the real read).
- const read = useCallback((): FilterMap => {
+ // The account's stored map, or the migrate seed when its key is still empty.
+ const [filters, setFilters] = useState(() => {
const raw = getAccountRaw(storageBase);
return raw ? hydrate(raw) : (migrate?.() ?? {});
- }, [storageBase, migrate]);
- const [filters, setFilters] = useState(read);
- const markDirty = useAccountRehydrate(() => setFilters(read()));
+ });
const set = useCallback(
(next: FilterMap) => {
- markDirty();
setFilters(next);
setAccountRaw(storageBase, JSON.stringify(next));
},
- [storageBase, markDirty],
+ [storageBase],
);
return [filters, set];
}
diff --git a/frontend/src/lib/useMe.ts b/frontend/src/lib/useMe.ts
index d5f126b0..4f5b0c99 100644
--- a/frontend/src/lib/useMe.ts
+++ b/frontend/src/lib/useMe.ts
@@ -2,8 +2,8 @@ import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type Me } from "./api";
-// The signed-in account, read from the qk.me() query cache. App owns the FETCHING query (gated on
-// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the
+// The signed-in account, read from the qk.me() query cache. BootGate owns the FETCHING query (gated on
+// setup state) and App renders modules only once it has resolved to a real user — so anywhere inside the
// authenticated module tree the cache is always populated. `enabled:false` makes this a passive
// observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache
// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook
diff --git a/frontend/src/lib/usePref.dom.test.ts b/frontend/src/lib/usePref.dom.test.ts
index e382b524..05c5bc67 100644
--- a/frontend/src/lib/usePref.dom.test.ts
+++ b/frontend/src/lib/usePref.dom.test.ts
@@ -2,10 +2,10 @@
//
// usePref: the one per-account + server preference hook. Covers the three seed sources (server cache /
// localStorage mirror / default), the persist path (server PUT + optimistic `me` cache merge + mirror),
-// debounced writes, adopt-on-login, parse, and the raw useSavePref persister. The `me` cache is the
+// debounced writes, parse, and the raw useSavePref persister. The `me` cache is the
// server source of truth here; storage is an in-memory account-keyed map; api.savePrefs is a spy.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import { renderHook, act, waitFor } from "@testing-library/react";
+import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
@@ -125,19 +125,6 @@ describe("usePref", () => {
expect(savePrefs).toHaveBeenCalledWith({ playerLoop: "all" });
});
- it("adopts the server value when the account id changes (login / switch)", async () => {
- const qc = makeClient();
- seedMe(qc, { view: "cards" }, 1);
- const { result } = renderHook(() => usePref("view", "cards"), {
- wrapper: wrapperFor(qc),
- });
- expect(result.current[0]).toBe("cards");
- // A different account resolves. The passive me observer notifies asynchronously (react-query's
- // notifyManager batches), so wait for the re-render — the same path the real App drives on login.
- act(() => seedMe(qc, { view: "tiles" }, 2));
- await waitFor(() => expect(result.current[0]).toBe("tiles"));
- });
-
describe("debounceMs", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
diff --git a/frontend/src/lib/usePref.ts b/frontend/src/lib/usePref.ts
index 66b6ced2..cab9c27b 100644
--- a/frontend/src/lib/usePref.ts
+++ b/frontend/src/lib/usePref.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
+import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api } from "./api";
import type { Me } from "./api/account";
@@ -14,24 +14,19 @@ const reportPrefSaveFailure = (err: unknown) =>
notifyActionError(err, i18n.t("errors.writeFailed.prefs"));
// The ONE per-account + server preference hook. A preference (unlike an ephemeral filter) persists to
-// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) AND is adopted
-// back on login / account switch. Before this, every such pref hand-rolled the same three pieces at its
-// call site: (1) seed local state, (2) on change write the server + a local mirror + the `me` cache,
-// (3) a `useEffect([me?.id])` block to adopt the server value when the account resolves. usePref folds
-// all three into one hook so a new server-synced pref is a one-liner.
+// the account on the server (PUT /api/me/preferences, which MERGES the partial payload) and follows the
+// account across devices. usePref folds what every such pref used to hand-roll — seed local state, and
+// on change write the server + an optional local mirror + the `me` cache — into one hook, so a new
+// server-synced pref is a one-liner.
//
-// Two mirror strategies, unified here (pick via `localKey`):
-// • Providers mounted ABOVE (feed view, panel layouts, rail-collapse flags) render before
-// /api/me pins the account, so they need a synchronous localStorage seed to avoid a flash on
-// reload — pass `localKey`.
-// • Late-mounting consumers (the player, the Plex detail pages) mount after `me` is cached, so they
-// seed straight from the react-query cache and need no localStorage mirror — omit `localKey`.
-// Either way the source of truth is the server; the `me` cache is kept in sync optimistically so other
+// 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, 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 {
- /** Also mirror to per-account localStorage under this base key, and seed synchronously from it on
- * first paint. Use for prefs whose owner mounts before the account is pinned; omit otherwise. */
+ /** Also mirror to per-account localStorage under this base key; that copy seeds the value when the
+ * server payload lacks the key. */
localKey?: string;
/** Debounce the server PUT by N ms so a slider drag / rapid toggles coalesce into one request. The
* local state + cache still update instantly; only the network write is deferred. */
@@ -70,8 +65,8 @@ export function useSavePref(): (patch: Record) => void {
);
}
-/** A per-account preference persisted to the server (and optionally mirrored to localStorage), with
- * automatic adopt on login / account switch. Returns `[value, setValue]`; the setter is stable. */
+/** A per-account preference persisted to the server (and optionally mirrored to localStorage).
+ * Returns `[value, setValue]`; the setter is stable. */
export function usePref(
serverKey: string,
defaultValue: T,
@@ -79,11 +74,9 @@ export function usePref(
): [T, (next: T) => void] {
const { localKey, debounceMs, parse } = options;
const qc = useQueryClient();
- // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
- const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
- // 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 => {
@@ -105,7 +98,7 @@ export function usePref(
const saveTimer = useRef(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(
() => () => {
@@ -120,8 +113,8 @@ export function usePref(
(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
@@ -141,20 +134,5 @@ export function usePref(
[qc, serverKey, localKey, debounceMs],
);
- // Adopt the server value when the account becomes known / changes (login, account switch). Guarded on
- // the account id so a `me` cache update from our OWN setter (same id) can't re-fire it. Missing key →
- // keep the current (seeded / user-set) value rather than clobbering it with the default.
- const prevId = useRef(me?.id);
- useEffect(() => {
- if (me?.id === prevId.current) return;
- prevId.current = me?.id;
- const server = readServerPref(me, serverKey);
- if (server === undefined) return;
- const val = norm(server);
- setValue(val);
- if (localKey) writeAccount(localKey, val);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [me?.id]);
-
return [value, set];
}
diff --git a/frontend/src/lib/useSortState.dom.test.ts b/frontend/src/lib/useSortState.dom.test.ts
index 758e4249..b77c1155 100644
--- a/frontend/src/lib/useSortState.dom.test.ts
+++ b/frontend/src/lib/useSortState.dom.test.ts
@@ -6,26 +6,19 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
-// `ctl.account` models the tab's pinned account: getAccountRaw returns null while it's null (mirrors the
-// real accountKey → no key → null), so a fresh-tab mount reads defaults until the account is pinned.
-const { store, ctl } = vi.hoisted(() => ({
- store: new Map(),
- ctl: { account: 1 as number | null },
-}));
+const { store } = vi.hoisted(() => ({ store: new Map() }));
vi.mock("./storage", () => ({
- getAccountRaw: (k: string) => (ctl.account == null ? null : (store.get(k) ?? null)),
+ getAccountRaw: (k: string) => store.get(k) ?? null,
setAccountRaw: (k: string, v: string) => {
- if (ctl.account != null) store.set(k, v);
+ store.set(k, v);
},
}));
-vi.mock("./activeAccount", () => ({ useActiveAccountId: () => ctl.account }));
import { useSortState } from "./useSortState";
describe("useSortState", () => {
beforeEach(() => {
store.clear();
- ctl.account = 1;
});
it("hydrates to the fallback when storage is empty", () => {
@@ -62,29 +55,4 @@ describe("useSortState", () => {
const { result } = renderHook(() => useSortState("k"));
expect(result.current[0]).toEqual({ key: "anything", dir: "desc" });
});
-
- // The account-pin re-hydrate (via useAccountRehydrate) integrated into useSortState.
- it("re-hydrates the stored sort once the account is pinned after mount", () => {
- ctl.account = null; // fresh tab: no account at mount
- store.set("k", JSON.stringify({ key: "views", dir: "desc" }));
- const { result, rerender } = renderHook(() => useSortState("k"));
- expect(result.current[0]).toBeNull(); // mount read with no account → default
- act(() => {
- ctl.account = 1; // pins the account
- rerender();
- });
- expect(result.current[0]).toEqual({ key: "views", dir: "desc" });
- });
-
- it("does NOT clobber a pre-pin user edit when the account is then pinned (markDirty guard)", () => {
- ctl.account = null;
- store.set("k", JSON.stringify({ key: "views", dir: "desc" })); // the stored value
- const { result, rerender } = renderHook(() => useSortState("k"));
- act(() => result.current[1]({ key: "name", dir: "asc" })); // user edits before the pin
- act(() => {
- ctl.account = 1;
- rerender();
- });
- expect(result.current[0]).toEqual({ key: "name", dir: "asc" }); // the edit survives, not "views"
- });
});
diff --git a/frontend/src/lib/useSortState.ts b/frontend/src/lib/useSortState.ts
index b9ad1c66..332b26f3 100644
--- a/frontend/src/lib/useSortState.ts
+++ b/frontend/src/lib/useSortState.ts
@@ -1,6 +1,5 @@
import { useCallback, useState } from "react";
import { getAccountRaw, setAccountRaw } from "./storage";
-import { useAccountRehydrate } from "./useAccountRehydrate";
import { hydrateSort, type SortOption, type SortState } from "./columnSort";
// The per-account persisted sort state, hydrated + healed against the current options — the plumbing
@@ -14,17 +13,12 @@ export function useSortState(
const [sort, setSort] = useState(() =>
hydrateSort(getAccountRaw(storageBase), options, fallback),
);
- // When the account is pinned after mount (first load), re-read the stored sort for that account.
- const markDirty = useAccountRehydrate(() =>
- setSort(hydrateSort(getAccountRaw(storageBase), options, fallback)),
- );
const set = useCallback(
(next: SortState) => {
- markDirty();
setSort(next);
setAccountRaw(storageBase, JSON.stringify(next));
},
- [storageBase, markDirty],
+ [storageBase],
);
return [sort, set];
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 925ec233..da369038 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -5,6 +5,7 @@ import { createQueryClient } from "./lib/queryClient";
import { FloatingTree } from "@floating-ui/react";
import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
+import BootGate, { BootScreen } from "./components/BootGate";
import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { SavedViewsProvider } from "./components/SavedViewsProvider";
@@ -18,13 +19,14 @@ import { WizardProvider } from "./components/WizardProvider";
import "./i18n";
import "./index.css";
import "flag-icons/css/flag-icons.min.css";
+import { lazyWithPreload } from "./lib/lazyWithPreload";
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a
// legal page never downloads the authenticated app bundle, and the app never carries them either.
// The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers
// can fetch them directly; the watch page is a login-free public player. Reached by full-page
// navigation (plain ), so a simple pathname switch is enough — no router needed.
-const App = lazy(() => import("./App"));
+const App = lazyWithPreload(() => import("./App"));
const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy"));
const Terms = lazy(() => import("./components/legal/Terms"));
const WatchPage = lazy(() => import("./components/WatchPage"));
@@ -39,6 +41,11 @@ de.theme ||= "dark";
de.scheme ||= "midnight";
const path = window.location.pathname;
+const isPublicPage = path === "/privacy" || path === "/terms" || path.startsWith("/watch/");
+// Fetch the app chunk NOW, alongside BootGate's identity probe, instead of only once the gate opens:
+// the two waits then overlap rather than queue, and the gate usually opens straight onto the app. A
+// failed fetch is left to the render's own load, which retries it and reports to the ErrorBoundary.
+if (!isPublicPage) App.preload().catch(() => {});
const root =
path === "/privacy" ? (
@@ -56,29 +63,37 @@ const root =
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
-
-
-
-
-
- {/* SavedViewsProvider sits below BOTH filter providers so it can switch its
+ {/* Nothing below renders until the tab knows which account it is: every provider
+ seeds per-account state in its initializer (see BootGate). */}
+
+
+
+
+
+
+ {/* SavedViewsProvider sits below BOTH filter providers so it can switch its
source (feed ⟷ plex) by the active page and read the right live filters. */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {/* The same quiet screen as the gate's, so a chunk that is still
+ loading when the gate opens continues it instead of blanking. */}
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 1048b8db..970969ee 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -11,7 +11,10 @@ const target = "http://127.0.0.1:8080";
// server returns to :5173 instead of the fixed :8080 callback. (Both are registered in the console.)
const proxyOpts = { target, changeOrigin: false };
const proxy = {
- "/api": proxyOpts,
+ // ws: the in-app messages socket (/api/messages/ws) is a WebSocket. Without it Vite hands that upgrade
+ // to nobody, so the socket never connects on :5173 — and the first reset on it (a closed tab, a dropped
+ // connection) throws an unhandled ECONNRESET that takes the whole dev server down.
+ "/api": { ...proxyOpts, ws: true },
"/auth": proxyOpts,
"/healthz": proxyOpts,
};