From aea12974529f11122a33033a85e107dfc3df10d4 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 7 Oct 2026 10:23:45 +0800 Subject: [PATCH 1/6] fix(home): Volume sort with the 1h and 24h windows no longer 502s, and a failed list stays inline The window volumes live on the lateral w as v1 and v24; the ORDER BY named w1.v and w24.v, which do not exist. The column per window is now one table the test checks against the query. A failed list read shows an inline error with Try again, and picking a view on the home page changes the address with history.replaceState instead of re-rendering the page on the server, where a failing render unmounted it. --- app/src/components/launchpad/LaunchList.tsx | 41 +++++++++++++------ .../components/launchpad/home-floor.test.ts | 3 +- .../launchpad/list-load-errors.test.ts | 33 +++++++++++++++ app/src/lib/launchpad/queries.ts | 6 ++- app/src/lib/launchpad/volume-window.test.ts | 30 ++++++++++++++ 5 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 app/src/components/launchpad/list-load-errors.test.ts create mode 100644 app/src/lib/launchpad/volume-window.test.ts diff --git a/app/src/components/launchpad/LaunchList.tsx b/app/src/components/launchpad/LaunchList.tsx index d8282f8d..286f4f9b 100644 --- a/app/src/components/launchpad/LaunchList.tsx +++ b/app/src/components/launchpad/LaunchList.tsx @@ -84,6 +84,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor const interaction = useRef({ pointer: false, focus: false, at: 0 }); const listRef = useRef(null); const section = usePauseOffscreen(); // a beacon pulses on every live row: not while the list is far off screen + const failedLoad = useRef<"view" | "more">("view"); useEffect(() => { // the watchlist reads its own endpoint; the shared poll only carries the market list while it is on screen @@ -138,7 +139,11 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor } }), [subscribe]); - /** The URL carries the view as well as the selection, so going back to the page reopens the same tab. */ + /** + * The URL carries the view as well as the selection, so going back to the page reopens the same tab. On the home page + * the list already holds the data, so only the address changes (history.replaceState syncs with the Next router): + * router.replace would re-render the whole page on the server, and a failing render there unmounts the page. + */ function syncUrl(next: Selection, v: ListView): URLSearchParams { const p = new URLSearchParams(); if (next.sort !== "live") p.set("sort", next.sort); @@ -146,7 +151,9 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor if (next.chain) p.set("chain", next.chain); if (next.filter) p.set("filter", next.filter); if (v === "watchlist") p.set("view", "watchlist"); - router.replace(p.size ? `/?${p}` : "/", { scroll: false }); + const href = p.size ? `/?${p}` : "/"; + if (window.location.pathname === "/") window.history.replaceState(null, "", href); + else router.replace(href, { scroll: false }); // a chain page hands over to the home page return p; } @@ -158,22 +165,27 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor } setView(v); selectionRef.current = next; - const version = ++generation.current; - selectionRequest.current?.abort(); - const controller = new AbortController(); - selectionRequest.current = controller; pendingOrder.current = null; setHolding(false); setSelection(next); limitRef.current = PAGE_SIZE; setLimit(PAGE_SIZE); setLoadingMore(false); + syncUrl(next, v); + loadSelection(next); + } + + /** Fetches the first page of a selection. A failure leaves the rows on screen and offers a retry; it never throws into render. */ + function loadSelection(next: Selection) { + const version = ++generation.current; + selectionRequest.current?.abort(); + const controller = new AbortController(); + selectionRequest.current = controller; setUpdating(true); setLoadError(null); - const request = syncUrl(next, v); - request.delete("view"); - request.set("sort", s); // the URL omits the default sort, but the API defaults to "new": the request must always carry it - request.set("limit", String(PAGE_SIZE)); + const request = new URLSearchParams({ sort: next.sort, window: next.window, limit: String(PAGE_SIZE) }); // the API defaults the sort to "new": the request must always carry it + if (next.chain) request.set("chain", next.chain); + if (next.filter) request.set("filter", next.filter); void fetch(`/api/launch/list?${request}`, { cache: "no-store", signal: controller.signal }) .then(async (res) => { if (!res.ok) throw new Error("List unavailable"); @@ -185,7 +197,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor setHasMore(data.has_more); setHl(new Map()); }) - .catch(() => { if (!controller.signal.aborted && generation.current === version) setLoadError("Could not refresh this view. Live updates will retry."); }) + .catch(() => { if (!controller.signal.aborted && generation.current === version) { failedLoad.current = "view"; setLoadError("Could not refresh this view."); } }) .finally(() => { if (generation.current === version) setUpdating(false); }); } @@ -233,7 +245,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor limitRef.current = Math.min(200, merged.length); setLimit(limitRef.current); } catch { - if (generation.current === version) setLoadError("Could not load more launches. Please try again."); + if (generation.current === version) { failedLoad.current = "more"; setLoadError("Could not load more launches."); } } finally { if (generation.current === version) setLoadingMore(false); } @@ -342,7 +354,10 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor {nq || filter || chain ? : Launch the first token