From 61a256acd1d799fedde4ded5b6f8653f13ddd021 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 21 Jun 2026 16:04:03 +0200 Subject: [PATCH] fix(compare): drop loading.tsx + hasSharedBenches gate in metadata PR #613 tried to short-circuit invalid /compare/-vs- pairs via notFound() in generateMetadata, but Next 16 still streamed the loading.tsx skeleton with HTTP 200 and the metadata switched to noindex mid-stream. Crawlers indexed an empty 'Loading live measurements' shell with noindex across the whole ad-hoc surface. Two changes: 1. Add hasSharedBenches() to compare-compute (pure set arithmetic on already-loaded provider appearances) and call it in generateMetadata so a pair like alchemy-vs-helius (both providers exist, share zero benches) hits notFound() at the metadata phase. Without this the page body's shared.length === 0 check fired only after loading.tsx had streamed, so the response was 200 + skeleton + a noindex meta tag from not-found.tsx. 2. Drop loading.tsx. With it in place, Next.js streams the skeleton first and the eventual notFound() can never demote the status code to 404 (response headers are already sent). Without it, invalid pairs cleanly return HTTP 404 with the not-found.tsx body and curated pairs render in one pass after the data resolves. Cold ad-hoc pairs lose the skeleton but the KV pair cache makes subsequent hits warm, and curated pairs are prerendered anyway. Verified locally (pnpm build + pnpm start): /compare/alchemy-vs-quicknode -> 404 Page not found (quicknode missing) /compare/mobula-vs-coingecko -> 308 then 404 (coingecko missing) /compare/alchemy-vs-helius -> 404 Page not found (no shared bench) /compare/quicknode-vs-alchemy -> 308 to canonical /compare/binance-vs-bybit -> 200 real h1, 50 KB content /compare/ethereum-vs-solana -> 200 real h1, 63 KB content --- src/app/compare/[slug]/loading.tsx | 84 ------------------------------ src/app/compare/[slug]/page.tsx | 9 ++++ src/lib/compare-compute.ts | 32 ++++++++++++ 3 files changed, 41 insertions(+), 84 deletions(-) delete mode 100644 src/app/compare/[slug]/loading.tsx diff --git a/src/app/compare/[slug]/loading.tsx b/src/app/compare/[slug]/loading.tsx deleted file mode 100644 index 2597da75..00000000 --- a/src/app/compare/[slug]/loading.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Loading UI rendered by Next.js during navigation to /compare/[slug]. - * Picked up automatically when the route's async render is in flight, - * which is the visible window where ad-hoc (non-curated) pairs pay the - * full cold start cost: every loadBenchmark for every shared bench - * fans out chain + region variant fetches. Without this file the user - * sees a frozen current page while the browser waits on the route - * payload; with it the visitor gets instant feedback that the compare - * page is building. - */ -export default function ComparePairLoading() { - return ( -
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-

- Loading live measurements -

-
- {Array.from({ length: 3 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
-

- First hit on a brand new pair can take a few seconds while the - per chain and per region variants fan out. Subsequent visits - and other users land on the cached render. -

-
-
- ); -} diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index dcf68a45..c4bff5a8 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -22,6 +22,7 @@ import { buildSharedBenches, canonicalisationTarget, fmtTs, + hasSharedBenches, latestIso, parseAdHocSlug, type BreakdownRow, @@ -120,6 +121,14 @@ export async function generateMetadata({ if (!pair) notFound(); const { a, b } = await loadPairProviders(pair); if (!a || !b) notFound(); + // Final SSR gate: an ad-hoc pair can have both providers resolved yet + // share zero benches (e.g. an RPC provider vs an oracle). Without + // this notFound() the page body's `shared.length === 0` check fires + // after loading.tsx has already streamed the shell, so the response + // ships HTTP 200 + skeleton + a noindex meta from not-found.tsx — + // exactly what crawlers indexed before this fix. Cheap: only the + // appearance intersection, no Prom fan out. + if (!hasSharedBenches(pair, a, b)) notFound(); const title = `${a.name} vs ${b.name}: live OpenChainBench benchmark data`; const description = capDescription( diff --git a/src/lib/compare-compute.ts b/src/lib/compare-compute.ts index a4130be8..736fda69 100644 --- a/src/lib/compare-compute.ts +++ b/src/lib/compare-compute.ts @@ -248,6 +248,38 @@ export async function loadChainRegionMatrix( return entries; } +/** Lightweight precheck: does this pair have at least one shared bench + * after applying the whitelist + exclude rules? Pure set arithmetic on + * the already-loaded provider appearances. No Prom calls, no KV + * lookup, no fan out. + * + * Used by `generateMetadata` so the route can `notFound()` BEFORE + * Next.js streams the loading.tsx fallback. Without this, an + * unresolvable ad-hoc pair (two real providers that share zero benches, + * e.g. an RPC provider vs an oracle) ships HTTP 200 + the loading + * skeleton + `Page not found` because the + * `shared.length === 0` check inside the page body fires after the + * Suspense boundary has already streamed the shell. The result was + * Google indexing the skeleton with `robots: noindex` for the entire + * /compare ad-hoc surface. + * + * Mirrors the candidate-slug computation inside `buildSharedBenches` + * so the two stay in lockstep. */ +export function hasSharedBenches( + pair: ComparePair, + aAppearances: Awaited>, + bAppearances: Awaited>, +): boolean { + if (!aAppearances || !bAppearances) return false; + const aSlugs = new Set(aAppearances.appearances.map((x) => x.benchmark.slug)); + const bSlugs = new Set(bAppearances.appearances.map((x) => x.benchmark.slug)); + const candidateSlugs = pair.benchmarks + ? pair.benchmarks.filter((s) => aSlugs.has(s) && bSlugs.has(s)) + : Array.from(aSlugs).filter((s) => bSlugs.has(s)); + const excluded = new Set(pair.excludeBenchmarks ?? []); + return candidateSlugs.some((s) => !excluded.has(s)); +} + /** Resolves the intersection of two providers' bench appearances, then * enriches each shared bench with aggregate + per chain + per region * breakdowns. Honors the pair's `benchmarks` whitelist (when set) and