diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx index 74f9002e..20784faf 100644 --- a/app/src/components/launchpad/LaunchForm.tsx +++ b/app/src/components/launchpad/LaunchForm.tsx @@ -873,7 +873,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D const active = initialBuy.trim() === v; return ( ); 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