From cfd317beabc00b32d0cc1e06dddd3de9592e5c4b Mon Sep 17 00:00:00 2001 From: Aditya Sanjeev Date: Fri, 14 Aug 2026 23:18:17 -0700 Subject: [PATCH] Make Dataset tab switches render instantly from a shared curated cache /api/search sends no Cache-Control, so the browser never reuses the request, and Homepage is a plain top-level route that fully unmounts on tab switch. Every Team/Overview -> Dataset switch paid a fresh network round trip plus a loading-spinner flash, even though the curated grid is deterministic and the existing idle warm-up had just fetched the same data moments earlier and thrown the result away (it only used it to preload images). Add helpers/curatedCache.ts as the single source of truth for that fetch: a module-scope cache both the app-boot warm-up and useDashboard's mount effect read/write, de-duped via an in-flight promise. useDashboard now seeds its initial state from the cache via a lazy useState initializer when present, so a warm tab switch renders the grid on the very first paint with no skeleton and no request. Cold loads (first visit before the warm-up settles, or a real fetch failure) fall back to the previous fetch-and-wait behavior unchanged. --- PanTS-Demo/src/App.tsx | 6 +- PanTS-Demo/src/helpers/curatedCache.ts | 87 +++++++++++++++++++ PanTS-Demo/src/helpers/prefetchCurated.ts | 41 --------- .../src/routes/Homepage/hooks/useDashboard.ts | 76 ++++++++-------- 4 files changed, 132 insertions(+), 78 deletions(-) create mode 100644 PanTS-Demo/src/helpers/curatedCache.ts delete mode 100644 PanTS-Demo/src/helpers/prefetchCurated.ts diff --git a/PanTS-Demo/src/App.tsx b/PanTS-Demo/src/App.tsx index 98b9c31f..521db449 100644 --- a/PanTS-Demo/src/App.tsx +++ b/PanTS-Demo/src/App.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense, useEffect } from "react"; import { BrowserRouter, Navigate, Route, Routes } from "react-router"; import "./App.css"; -import { prefetchCurated } from "./helpers/prefetchCurated"; +import { warmCuratedCache } from "./helpers/curatedCache"; import AnalyticsRouteTracker from "./components/AnalyticsRouteTracker"; import AuthModal from "./components/AuthModal"; import { AnnotationProvider } from "./contexts/annotationContexts"; @@ -72,8 +72,8 @@ function App() { }; const ric = w.requestIdleCallback; const id = ric - ? ric(() => prefetchCurated()) - : window.setTimeout(prefetchCurated, 1200); + ? ric(() => warmCuratedCache()) + : window.setTimeout(warmCuratedCache, 1200); return () => { if (ric) w.cancelIdleCallback?.(id as number); else window.clearTimeout(id as number); diff --git a/PanTS-Demo/src/helpers/curatedCache.ts b/PanTS-Demo/src/helpers/curatedCache.ts new file mode 100644 index 00000000..6569291f --- /dev/null +++ b/PanTS-Demo/src/helpers/curatedCache.ts @@ -0,0 +1,87 @@ +import { API_BASE } from "./constants"; +import { itemToId, type SearchItem } from "./search"; + +// Shared, in-memory (module-scope, survives route unmount/remount) cache for the +// curated Dataset landing grid (tumor / no-tumor, sort_by=quality, interleaved). +// +// Why this exists: /api/search sends no Cache-Control, so the browser HTTP cache +// never reuses the request, and Homepage is a plain top-level route that fully +// unmounts on tab switch — so without this, EVERY Overview/Team -> Dataset switch +// paid a fresh network round trip + a loading-spinner flash, even though the data +// is deterministic and was already fetched moments earlier by the idle warm-up. +// This module is the single source of truth for that fetch; both the App-level +// warm-up and useDashboard's mount effect read/write it, so whichever runs first +// wins and the other gets an instant synchronous hit. +let cachedItems: SearchItem[] | null = null; +let inFlight: Promise | null = null; + +const HALF = 4; // mirrors CARD_COUNT / 2 in the dashboard's loadCurated + +function interleave(tumorItems: SearchItem[], noTumorItems: SearchItem[]): SearchItem[] { + const out: SearchItem[] = []; + for (let i = 0; i < Math.max(tumorItems.length, noTumorItems.length); i++) { + if (tumorItems[i]) out.push(tumorItems[i]); + if (noTumorItems[i]) out.push(noTumorItems[i]); + } + return out; +} + +function doFetch(): Promise { + const okJson = (r: Response) => (r.ok ? r.json() : null); + const grab = (tumor: 0 | 1) => + fetch(`${API_BASE}/api/search?tumor=${tumor}&sort_by=quality&per_page=${HALF}`) + .then(okJson) + .catch(() => null); + + return Promise.all([grab(1), grab(0)]).then(([tumorRes, noTumorRes]) => { + if (tumorRes == null && noTumorRes == null) { + // Total failure (both requests errored/non-OK) -- don't poison the cache + // with an empty result; let the next call retry instead of showing an + // empty grid for the rest of the session. + throw new Error("curated fetch failed"); + } + const items = interleave(tumorRes?.items ?? [], noTumorRes?.items ?? []); + cachedItems = items; + return items; + }); +} + +/** Synchronous read — null if nothing has resolved yet. */ +export function getCachedCurated(): SearchItem[] | null { + return cachedItems; +} + +/** + * Kick off (or join) the curated fetch. Safe to call repeatedly — de-duped via + * `inFlight` so the idle warm-up and an impatient mount effect never double-fetch. + */ +export function fetchCurated(): Promise { + if (cachedItems) return Promise.resolve(cachedItems); + if (inFlight) return inFlight; + inFlight = doFetch().finally(() => { + inFlight = null; + }); + return inFlight; +} + +/** + * Warm the cache and preload preview images. Called once shortly after app boot + * (idle time) so a later Dataset tab visit renders from memory. + */ +let warmStarted = false; +export function warmCuratedCache(): void { + if (warmStarted || typeof window === "undefined" || typeof fetch === "undefined") { + return; + } + warmStarted = true; + fetchCurated() + .then((items) => { + for (const it of items) { + const id = itemToId(it); + if (!id) continue; + const img = new Image(); + img.src = `${API_BASE}/api/get_image_preview/${id}`; + } + }) + .catch(() => {}); +} diff --git a/PanTS-Demo/src/helpers/prefetchCurated.ts b/PanTS-Demo/src/helpers/prefetchCurated.ts deleted file mode 100644 index 7697820d..00000000 --- a/PanTS-Demo/src/helpers/prefetchCurated.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { API_BASE } from "./constants"; -import { itemToId, type SearchItem } from "./search"; - -// Warm the Dataset landing grid (the deterministic "curated" set — the same -// quality-sorted tumor / no-tumor cases every visit) so that navigating there -// from another tab renders instantly instead of paying the search + thumbnail -// fetch on the click. On a real connection that fetch is what makes an -// Overview/Upload -> Dataset tab-switch feel slow even though a full page load -// (which folds the same fetch into the expected load) feels fine. -// -// Runs at most once per session and swallows all errors — it is a pure warm-up. -let started = false; - -export function prefetchCurated(): void { - if (started || typeof window === "undefined" || typeof fetch === "undefined") { - return; - } - started = true; - - const HALF = 4; // mirrors CARD_COUNT / 2 in the dashboard's loadCurated - const grab = (tumor: 0 | 1) => - fetch(`${API_BASE}/api/search?tumor=${tumor}&sort_by=quality&per_page=${HALF}`) - .then((r) => (r.ok ? r.json() : null)) - .catch(() => null); - - Promise.all([grab(1), grab(0)]) - .then((results) => { - for (const res of results) { - const items: SearchItem[] = res?.items ?? []; - for (const it of items) { - const id = itemToId(it); - if (!id) continue; - // Preload with the SAME url Preview will request (itemToId gives the - // exact numeric id), so the browser serves the card from cache. - const img = new Image(); - img.src = `${API_BASE}/api/get_image_preview/${id}`; - } - } - }) - .catch(() => {}); -} diff --git a/PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts b/PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts index 51ee6578..e4286c21 100644 --- a/PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts +++ b/PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts @@ -13,6 +13,7 @@ import { type SearchItem, } from "../../../helpers/search"; import { prefetchViewer } from "../../../helpers/prefetchViewer"; +import { fetchCurated, getCachedCurated } from "../../../helpers/curatedCache"; import { loadSavedCases, SAVED_CASES_EVENT, @@ -25,15 +26,40 @@ import { CARD_COUNT, PER_PAGE } from "../constants"; import type { FacetData } from "../types"; import { track } from "../../../helpers/analytics"; +// Pure so it can seed both the lazy initial state (skips the first-paint skeleton +// entirely when the curated cache is already warm) and the post-fetch ingest path. +function toPreviewData(items: SearchItem[]) { + const ids: CaseId[] = []; + const meta: { [key: string]: PreviewType } = {}; + for (const it of items) { + const id = itemToId(it); + if (!id) continue; + ids.push(id); + meta[id] = { + sex: it.sex ?? "", + age: Number(it.age) || 0, + tumor: it.tumor === 1 ? 1 : it.tumor === 0 ? 0 : null, + }; + } + return { ids, meta }; +} + export function useDashboard() { - const [previewIds, setPreviewIds] = useState([]); const navigation = useNavigate(); - const [previewMetadata, setPreviewMetadata] = useState<{ [key: string]: PreviewType }>({}); - const [loading, setLoading] = useState(true); - const [searchId, setSearchId] = useState(0); const [searchParams, setSearchParams] = useSearchParams(); - const [showFilters, setShowFilters] = useState(false); const [filters, setFilters] = useState(() => parseFiltersFromParams(searchParams)); + // Only the curated (no-filter) view is cacheable; a filtered/deep-linked URL + // always does a live fetch, same as before. `filters` was just initialized from + // the same searchParams, so reuse it instead of re-parsing. + const initialCached = countActiveFilters(filters) === 0 ? getCachedCurated() : null; + const initialData = initialCached ? toPreviewData(initialCached) : null; + const [previewIds, setPreviewIds] = useState(initialData?.ids ?? []); + const [previewMetadata, setPreviewMetadata] = useState<{ [key: string]: PreviewType }>( + initialData?.meta ?? {}, + ); + const [loading, setLoading] = useState(!initialData); + const [searchId, setSearchId] = useState(0); + const [showFilters, setShowFilters] = useState(false); const [facetData, setFacetData] = useState(null); const [matchTotal, setMatchTotal] = useState(null); const [page, setPage] = useState(1); @@ -89,18 +115,7 @@ export function useDashboard() { const handleClearCompare = () => setCompareIds([]); const ingestItems = (items: SearchItem[]) => { - const ids: CaseId[] = []; - const meta: { [key: string]: PreviewType } = {}; - for (const it of items) { - const id = itemToId(it); - if (!id) continue; - ids.push(id); - meta[id] = { - sex: it.sex ?? "", - age: Number(it.age) || 0, - tumor: it.tumor === 1 ? 1 : it.tumor === 0 ? 0 : null, - }; - } + const { ids, meta } = toPreviewData(items); setPreviewMetadata(meta); setPreviewIds(ids); setLoading(false); @@ -108,27 +123,20 @@ export function useDashboard() { }; // Curated cases: fullest-body scans split half tumor / half no-tumor, interleaved. + // Reads the shared module-scope cache first: if the app-boot idle warm-up (or an + // earlier mount) already resolved it, this renders synchronously from memory with + // no spinner and no network round trip -- the fix for Team/Overview -> Dataset + // tab switches paying a full refetch every time despite the data being static. const loadCurated = async () => { + const cached = getCachedCurated(); + if (cached) { + ingestItems(cached); + return; + } setLoading(true); setPreviewMetadata({}); - const half = CARD_COUNT / 2; try { - const okJson = (r: Response) => { - if (!r.ok) throw new Error(`Curated load failed (${r.status})`); - return r.json(); - }; - const [tumorRes, noTumorRes] = await Promise.all([ - fetch(`${API_BASE}/api/search?tumor=1&sort_by=quality&per_page=${half}`).then(okJson), - fetch(`${API_BASE}/api/search?tumor=0&sort_by=quality&per_page=${half}`).then(okJson), - ]); - const tumorItems: SearchItem[] = tumorRes.items ?? []; - const noTumorItems: SearchItem[] = noTumorRes.items ?? []; - const interleaved: SearchItem[] = []; - for (let i = 0; i < Math.max(tumorItems.length, noTumorItems.length); i++) { - if (tumorItems[i]) interleaved.push(tumorItems[i]); - if (noTumorItems[i]) interleaved.push(noTumorItems[i]); - } - ingestItems(interleaved); + ingestItems(await fetchCurated()); } catch (e) { console.error(e); setLoading(false);