From 33cdf3e101ec0826754abd0eb3b88b081b77d467 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sun, 21 Jun 2026 16:07:28 +0200 Subject: [PATCH] hotfix(compare): drop loading.tsx + hasSharedBenches gate (cherry-pick #621) PR #613 left valid ad-hoc /compare/-vs- URLs serving HTTP 200 + a loading skeleton + noindex meta. With loading.tsx in place, Next 16 streams the skeleton with 200 before the page body's notFound() can demote the status. Plus generateMetadata only checked that both providers exist, missing the no-shared-bench case. This hotfix lands the same fix as PR #621 (dev) but inline on main's older file structure (no extracted compare-compute helper module yet). hasSharedBenches() is a pure set arithmetic helper on appearances, no Prom fan out. loading.tsx removal lets notFound() ship a real 404. Verified locally: /compare/alchemy-vs-quicknode -> 404 (quicknode missing) /compare/mobula-vs-coingecko -> 308 then 404 (coingecko missing) /compare/alchemy-vs-helius -> 404 (no shared bench) /compare/quicknode-vs-alchemy -> 308 to canonical /compare/binance-vs-bybit -> 200 real h1 (valid ad-hoc) /compare/ethereum-vs-solana -> 200 real h1 (curated) --- src/app/compare/[slug]/loading.tsx | 84 ------------------------------ src/app/compare/[slug]/page.tsx | 40 +++++++++++--- 2 files changed, 34 insertions(+), 90 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 f156dc8a..0e32f3da 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -122,6 +122,30 @@ function canonicalisationTarget(slug: string): string | null { return canonical === slug ? null : canonical; } +/** 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. + * + * Mirrors the candidate-slug computation inside `buildSharedBenches` + * so the two stay in lockstep. Called by `generateMetadata` so a pair + * whose providers both exist but share zero benches notFound()s + * before any HTML streams. */ +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)); +} + export async function generateMetadata({ params, }: { @@ -129,18 +153,22 @@ export async function generateMetadata({ }): Promise { const { slug } = await params; // Run the same gating logic as the page render so non-canonical and - // invalid slugs short-circuit at the metadata phase, BEFORE Next.js - // streams the loading.tsx fallback. Without this the SSR HTML ends - // up with the root layout's homepage title and description plus the - // skeleton body, which is exactly what crawlers index. Routing here - // produces a real 308 / 404 response from the route layer instead of - // a 200 wrapping the streamed skeleton. + // invalid slugs short-circuit at the metadata phase. Combined with + // the loading.tsx removal in this hotfix, notFound() here cleanly + // produces a real 308 / 404 response from the route layer instead + // of a 200 wrapping a streamed loading skeleton. const canonicalTarget = canonicalisationTarget(slug); if (canonicalTarget) redirect(`/compare/${canonicalTarget}`); const pair = getComparePair(slug) ?? (await resolveAdHocPair(slug)); 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 the page body's `shared.length === 0` check fires + // late and the response loses its chance to demote the status code. + // Cheap: only the appearance intersection, no Prom fan out. + if (!hasSharedBenches(pair, a, b)) notFound(); const url = `${SITE.url}/compare/${pair.slug}`; const title = `${a.name} vs ${b.name}: live OpenChainBench benchmark data`;