diff --git a/frontend/src/components/ui/QueryState.dom.test.tsx b/frontend/src/components/ui/QueryState.dom.test.tsx
index cdfc420e..089e147b 100644
--- a/frontend/src/components/ui/QueryState.dom.test.tsx
+++ b/frontend/src/components/ui/QueryState.dom.test.tsx
@@ -56,6 +56,16 @@ describe("LoadingState reveal", () => {
expect(revealDelay(container)).toBe(300);
});
+ it("one that mounts just after another left, in a later commit, keeps that one's clock", () => {
+ now = 45_000;
+ const first = render(
);
+ now = 45_050;
+ first.unmount(); // a Suspense fallback leaves a commit before the page's own loading state mounts
+ now = 45_080;
+ const { container } = render(
);
+ expect(revealDelay(container)).toBe(220);
+ });
+
it("`since` counts the wait from an earlier start (the boot screen: navigation start)", () => {
now = 50_250;
const { container } = render(
);
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/main.tsx b/frontend/src/main.tsx
index 7a5cecae..da369038 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -19,14 +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 loadApp = () => import("./App");
-const App = lazy(loadApp);
+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"));
@@ -43,8 +43,9 @@ 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.
-if (!isPublicPage) void loadApp();
+// 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" ? (