Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions PanTS-Demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
87 changes: 87 additions & 0 deletions PanTS-Demo/src/helpers/curatedCache.ts
Original file line number Diff line number Diff line change
@@ -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<SearchItem[]> | 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<SearchItem[]> {
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<SearchItem[]> {
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(() => {});
}
41 changes: 0 additions & 41 deletions PanTS-Demo/src/helpers/prefetchCurated.ts

This file was deleted.

76 changes: 42 additions & 34 deletions PanTS-Demo/src/routes/Homepage/hooks/useDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<CaseId[]>([]);
const navigation = useNavigate();
const [previewMetadata, setPreviewMetadata] = useState<{ [key: string]: PreviewType }>({});
const [loading, setLoading] = useState(true);
const [searchId, setSearchId] = useState<number>(0);
const [searchParams, setSearchParams] = useSearchParams();
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<Filters>(() => 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<CaseId[]>(initialData?.ids ?? []);
const [previewMetadata, setPreviewMetadata] = useState<{ [key: string]: PreviewType }>(
initialData?.meta ?? {},
);
const [loading, setLoading] = useState(!initialData);
const [searchId, setSearchId] = useState<number>(0);
const [showFilters, setShowFilters] = useState(false);
const [facetData, setFacetData] = useState<FacetData | null>(null);
const [matchTotal, setMatchTotal] = useState<number | null>(null);
const [page, setPage] = useState(1);
Expand Down Expand Up @@ -89,46 +115,28 @@ 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);
return ids;
};

// 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);
Expand Down
Loading