From cdf97703a5d6d3b6ab9b24d4f45803071bbe1cda Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Sat, 23 May 2026 15:00:25 +0200 Subject: [PATCH] chore(vercel): disable github auto-deploy on main User wants the single-source-of-truth workflow: merge to main, then manual 'vercel --prod'. The github integration was firing a second parallel deploy on every merge - the race produced moments where two deploys raced for the prod alias and cache propagation looked weird. Disabling auto-deploy means only the explicit cli deploy runs, one per push. --- src/components/benchmark-body.tsx | 3 +- src/components/chain-headings-summary.tsx | 7 +-- src/components/count-leaderboard.tsx | 4 +- src/components/distribution-chart.tsx | 26 +++-------- src/components/donut-chart.tsx | 22 +++------ src/components/ledger-table.tsx | 4 +- src/components/mini-chart.tsx | 3 +- src/components/ranked-bar-chart.tsx | 29 +++--------- src/components/region-grid.tsx | 30 ++++++++----- src/hooks/use-chart-exclusion.ts | 54 +++++++++++++++++++++++ src/lib/bench-template.ts | 9 ++-- src/lib/citation.ts | 5 ++- src/lib/provider-filters.ts | 23 ++++++++++ src/lib/providers.ts | 5 +-- vercel.json | 5 +++ 15 files changed, 144 insertions(+), 85 deletions(-) create mode 100644 src/hooks/use-chart-exclusion.ts create mode 100644 src/lib/provider-filters.ts diff --git a/src/components/benchmark-body.tsx b/src/components/benchmark-body.tsx index e9f0dbd8..326d3573 100644 --- a/src/components/benchmark-body.tsx +++ b/src/components/benchmark-body.tsx @@ -3,6 +3,7 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import type { Benchmark } from "@/types/benchmark"; +import { liveResults } from "@/lib/provider-filters"; import { ChainTabs } from "@/components/chain-tabs"; import { LedgerTable } from "@/components/ledger-table"; import { TimeSeriesChart } from "@/components/time-series-chart"; @@ -70,7 +71,7 @@ function syncParam( function summarize(b: Benchmark | undefined): ChainMeta | null { if (!b) return null; - const live = b.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0); + const live = liveResults(b.results); if (live.length === 0) return { providers: 0, metric: b.metric }; const sorted = [...live].sort((a, c) => b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50 diff --git a/src/components/chain-headings-summary.tsx b/src/components/chain-headings-summary.tsx index 8ae86ad7..9c348ca0 100644 --- a/src/components/chain-headings-summary.tsx +++ b/src/components/chain-headings-summary.tsx @@ -1,4 +1,5 @@ import type { Benchmark } from "@/types/benchmark"; +import { liveResults } from "@/lib/provider-filters"; import { fmtUnit } from "@/lib/format"; /** @@ -18,8 +19,8 @@ import { fmtUnit } from "@/lib/format"; * No-op when the bench has no live data yet. */ export function ChainHeadingsSummary({ benchmark }: { benchmark: Benchmark }) { - const liveResults = benchmark.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0); - if (liveResults.length === 0) return null; + const live = liveResults(benchmark.results); + if (live.length === 0) return null; // Render for benches whose providers ARE chains - currently the // "Blockchains" category (l1-finality, l2-block-time, ...). Skip @@ -29,7 +30,7 @@ export function ChainHeadingsSummary({ benchmark }: { benchmark: Benchmark }) { if (benchmark.category !== "Blockchains") return null; // Sort by p50: best-first when lower-is-better, worst-first otherwise. - const sorted = [...liveResults].sort((a, b) => + const sorted = [...live].sort((a, b) => benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50 ); diff --git a/src/components/count-leaderboard.tsx b/src/components/count-leaderboard.tsx index 86cc43c7..0b864154 100644 --- a/src/components/count-leaderboard.tsx +++ b/src/components/count-leaderboard.tsx @@ -1,5 +1,7 @@ "use client"; +import { useMemo } from "react"; + import type { Benchmark } from "@/types/benchmark"; import { fmtValue } from "@/lib/format"; import { rankResults } from "@/lib/ranking"; @@ -15,7 +17,7 @@ import { buildProviderColors } from "@/lib/series-colors"; export function CountLeaderboard({ benchmark }: { benchmark: Benchmark }) { const ranked = rankResults(benchmark.results, benchmark.higherIsBetter); const max = Math.max(...ranked.map((r) => r.ms.p50)) || 1; - const colors = buildProviderColors(benchmark.results); + const colors = useMemo(() => buildProviderColors(benchmark.results), [benchmark.results]); const leader = ranked[0]; const trailer = ranked[ranked.length - 1]; diff --git a/src/components/distribution-chart.tsx b/src/components/distribution-chart.tsx index 2c4f686e..46fb431a 100644 --- a/src/components/distribution-chart.tsx +++ b/src/components/distribution-chart.tsx @@ -1,11 +1,13 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import type { Benchmark } from "@/types/benchmark"; +import { liveResults } from "@/lib/provider-filters"; import { ProviderLogo } from "@/components/provider-logo"; import { fmtUnit } from "@/lib/format"; import { methodologyTooltip } from "@/lib/methodology-tooltip"; import { buildProviderColors } from "@/lib/series-colors"; +import { useChartExclusion } from "@/hooks/use-chart-exclusion"; /** * Latency-spread view. One row per provider, three markers (p50 / p90 / @@ -32,28 +34,14 @@ export function DistributionChart({ onToggleExclude?: (slug: string) => void; }) { const { results, unit, higherIsBetter } = benchmark; - const [internalExcluded, setInternalExcluded] = useState>( - () => new Set(), + const { excluded, toggle } = useChartExclusion( + controlledExcluded, + onToggleExclude, ); - const excluded = controlledExcluded ?? internalExcluded; - const toggle = (slug: string) => { - if (onToggleExclude) { - onToggleExclude(slug); - return; - } - setInternalExcluded((prev) => { - const next = new Set(prev); - if (next.has(slug)) next.delete(slug); - else next.add(slug); - return next; - }); - }; const colors = useMemo(() => buildProviderColors(results), [results]); - const live = results.filter( - (r) => r.availability !== "unavailable" && r.ms.p50 > 0, - ); + const live = liveResults(results); if (live.length === 0) { return

No data.

; } diff --git a/src/components/donut-chart.tsx b/src/components/donut-chart.tsx index 0f14e988..d0d5a567 100644 --- a/src/components/donut-chart.tsx +++ b/src/components/donut-chart.tsx @@ -2,10 +2,12 @@ import { useMemo, useState } from "react"; import type { Benchmark } from "@/types/benchmark"; +import { liveResults } from "@/lib/provider-filters"; import { ProviderLogo } from "@/components/provider-logo"; import { fmtUnit } from "@/lib/format"; import { methodologyTooltip } from "@/lib/methodology-tooltip"; import { buildProviderColors } from "@/lib/series-colors"; +import { useChartExclusion } from "@/hooks/use-chart-exclusion"; /** * Share-of-field donut. Each provider is a slice sized by p50 vs the @@ -40,25 +42,13 @@ export function DonutChart({ onToggleExclude?: (slug: string) => void; }) { const { results } = benchmark; - const [internalExcluded, setInternalExcluded] = useState>( - () => new Set(), + const { excluded, toggle } = useChartExclusion( + controlledExcluded, + onToggleExclude, ); - const excluded = controlledExcluded ?? internalExcluded; - const toggle = (slug: string) => { - if (onToggleExclude) { - onToggleExclude(slug); - return; - } - setInternalExcluded((prev) => { - const next = new Set(prev); - if (next.has(slug)) next.delete(slug); - else next.add(slug); - return next; - }); - }; const liveAll = useMemo( - () => results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0), + () => liveResults(results), [results], ); const live = liveAll.filter((r) => !excluded.has(r.slug)); diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index 72ada369..80f58ec2 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -1,5 +1,7 @@ "use client"; +import { useMemo } from "react"; + import Link from "next/link"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; import { Sparkline } from "@/components/sparkline"; @@ -33,7 +35,7 @@ export function LedgerTable({ benchmark }: Props) { if (aOff !== bOff) return aOff - bOff; return benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50; }); - const colors = buildProviderColors(results); + const colors = useMemo(() => buildProviderColors(results), [results]); const allSeries = Object.values(extras.series24h).flat(); const sparkMin = allSeries.length ? Math.min(...allSeries) : 0; diff --git a/src/components/mini-chart.tsx b/src/components/mini-chart.tsx index c9152f7a..8c55b930 100644 --- a/src/components/mini-chart.tsx +++ b/src/components/mini-chart.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import type { Benchmark } from "@/types/benchmark"; import { buildProviderColors } from "@/lib/series-colors"; @@ -26,7 +27,7 @@ export function MiniChart({ legend = false, }: Props) { const width = viewBoxWidth; - const colors = buildProviderColors(benchmark.results); + const colors = useMemo(() => buildProviderColors(benchmark.results), [benchmark.results]); // Sort providers so the legend reads best → worst, matching the ledger // table on the detail page. Direction depends on the bench (lower vs diff --git a/src/components/ranked-bar-chart.tsx b/src/components/ranked-bar-chart.tsx index 1d04e9a9..f0f2be20 100644 --- a/src/components/ranked-bar-chart.tsx +++ b/src/components/ranked-bar-chart.tsx @@ -1,10 +1,11 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import type { Benchmark } from "@/types/benchmark"; import { fmtUnit } from "@/lib/format"; import { methodologyTooltip } from "@/lib/methodology-tooltip"; import { buildProviderColors } from "@/lib/series-colors"; +import { useChartExclusion } from "@/hooks/use-chart-exclusion"; import { LiveDot } from "@/components/live-dot"; import { ProviderLogo } from "@/components/provider-logo"; @@ -44,29 +45,11 @@ export function RankedBarChart({ onToggleExclude, onResetExcluded, }: Props) { - const [internalExcluded, setInternalExcluded] = useState>( - () => new Set(), + const { excluded, toggle, reset } = useChartExclusion( + controlledExcluded, + onToggleExclude, + onResetExcluded, ); - const excluded = controlledExcluded ?? internalExcluded; - const toggle = (slug: string) => { - if (onToggleExclude) { - onToggleExclude(slug); - return; - } - setInternalExcluded((prev) => { - const next = new Set(prev); - if (next.has(slug)) next.delete(slug); - else next.add(slug); - return next; - }); - }; - const reset = () => { - if (onResetExcluded) { - onResetExcluded(); - return; - } - setInternalExcluded(new Set()); - }; const colors = useMemo( () => buildProviderColors(benchmark.results), diff --git a/src/components/region-grid.tsx b/src/components/region-grid.tsx index 7d0b4e7e..1d8c70be 100644 --- a/src/components/region-grid.tsx +++ b/src/components/region-grid.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import type { Benchmark } from "@/types/benchmark"; import { fmtUnit } from "@/lib/format"; import { buildProviderColors } from "@/lib/series-colors"; @@ -14,19 +15,26 @@ const REGIONS = [ export function RegionGrid({ benchmark }: Props) { const { results, unit, extras } = benchmark; - if (!results.length) return null; - - const colors = buildProviderColors(results); - const regionMax = new Map(); - for (const region of REGIONS) { - let m = 0; - for (const r of results) { - const point = extras.regions[r.slug]?.find((p) => p.region === region.key); - if (point && point.p50 > m) m = point.p50; + // Both maps recompute O(n*m) over results × regions. Memoise so the + // grid doesn't reprice every cell on each parent re-render (parent + // re-renders on every chain/region tab change and every chart view + // switch, none of which touch results or extras.regions). + const colors = useMemo(() => buildProviderColors(results), [results]); + const regionMax = useMemo(() => { + const map = new Map(); + for (const region of REGIONS) { + let m = 0; + for (const r of results) { + const point = extras.regions[r.slug]?.find((p) => p.region === region.key); + if (point && point.p50 > m) m = point.p50; + } + map.set(region.key, m); } - regionMax.set(region.key, m); - } + return map; + }, [results, extras.regions]); + + if (!results.length) return null; return (
diff --git a/src/hooks/use-chart-exclusion.ts b/src/hooks/use-chart-exclusion.ts new file mode 100644 index 00000000..3f6cd1fe --- /dev/null +++ b/src/hooks/use-chart-exclusion.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; + +/** + * Per-chart "providers the reader chose to hide" state. + * + * Each chart (ranked-bar, distribution, donut) was hand-rolling this + * pattern: a controlled / uncontrolled switch + a local `Set` + * fallback + a toggle that adds-or-removes. Three copies of the same + * 25 lines, with the same edge cases to keep in sync. This hook is the + * single source of truth. + * + * - When the parent passes `controlled` + `onToggle`, the hook defers + * to the parent so the choice survives view switches. + * - When no controls are passed (chart embedded standalone, e.g. on a + * product page), the hook manages a local set and the exclusion is + * scoped to that chart's lifetime. + */ +export function useChartExclusion( + controlled?: Set, + onToggle?: (slug: string) => void, + onReset?: () => void, +): { + excluded: Set; + toggle: (slug: string) => void; + reset: () => void; +} { + const [internal, setInternal] = useState>(() => new Set()); + const excluded = controlled ?? internal; + + const toggle = (slug: string) => { + if (onToggle) { + onToggle(slug); + return; + } + setInternal((prev) => { + const next = new Set(prev); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + }; + + const reset = () => { + if (onReset) { + onReset(); + return; + } + setInternal(new Set()); + }; + + return { excluded, toggle, reset }; +} diff --git a/src/lib/bench-template.ts b/src/lib/bench-template.ts index e3c2d2d3..23a68e19 100644 --- a/src/lib/bench-template.ts +++ b/src/lib/bench-template.ts @@ -27,6 +27,7 @@ */ import type { Benchmark } from "@/types/benchmark"; +import { liveResults } from "@/lib/provider-filters"; import { fmtUnit } from "@/lib/format"; // Keyword allows digits ({{p50:slug}}, {{best_p50}}, {{worst_p99}}) and @@ -38,8 +39,8 @@ const TEMPLATE_RE = /\{\{\s*([a-z][a-z0-9_]*)(?::([a-z0-9-]+))?\s*\}\}/gi; export function renderTemplate(text: string, benchmark: Benchmark): string { if (!text || text.indexOf("{{") === -1) return text; - const liveResults = benchmark.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0); - const sorted = [...liveResults].sort((a, b) => + const live = liveResults(benchmark.results); + const sorted = [...live].sort((a, b) => benchmark.higherIsBetter ? b.ms.p50 - a.ms.p50 : a.ms.p50 - b.ms.p50 ); const best = sorted[0]; @@ -53,7 +54,7 @@ export function renderTemplate(text: string, benchmark: Benchmark): string { case "mean": case "name": { if (!arg) return whole; - const provider = liveResults.find( + const provider = live.find( (r) => r.slug.toLowerCase() === arg.toLowerCase() ); if (!provider) return whole; @@ -70,7 +71,7 @@ export function renderTemplate(text: string, benchmark: Benchmark): string { case "worst_p50": return worst ? fmtUnit(worst.ms.p50, benchmark.unit) : whole; case "count": - return String(liveResults.length); + return String(live.length); default: return whole; } diff --git a/src/lib/citation.ts b/src/lib/citation.ts index f1918f6d..63d22db8 100644 --- a/src/lib/citation.ts +++ b/src/lib/citation.ts @@ -5,12 +5,13 @@ */ import type { Benchmark } from "@/types/benchmark"; +import { liveResults } from "@/lib/provider-filters"; import { fmtUnit } from "@/lib/format"; /** Median value of the benchmark (the field shown in the headline). */ export function fieldValue(b: Benchmark): number | null { if (b.status !== "live") return null; - const live = b.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0); + const live = liveResults(b.results); if (live.length === 0) return null; const sorted = [...live].sort((a, c) => b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50 @@ -21,7 +22,7 @@ export function fieldValue(b: Benchmark): number | null { /** Who is currently #1 on this benchmark, if any. */ export function leader(b: Benchmark): { name: string; slug: string; value: number } | null { if (b.status !== "live") return null; - const live = b.results.filter((r) => r.availability !== "unavailable" && r.ms.p50 > 0); + const live = liveResults(b.results); if (live.length === 0) return null; const sorted = [...live].sort((a, c) => b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50 diff --git a/src/lib/provider-filters.ts b/src/lib/provider-filters.ts new file mode 100644 index 00000000..bb078b35 --- /dev/null +++ b/src/lib/provider-filters.ts @@ -0,0 +1,23 @@ +import type { ProviderResult } from "@/types/benchmark"; + +/** + * Field of providers the leaderboard treats as "live this cycle". + * + * Two conditions both need to hold: + * - `availability !== "unavailable"` — the spec loader marks providers + * with no Prom data this cycle as unavailable so the augmented zero + * entries don't poison the ranking. + * - `ms.p50 > 0` — defensive fallback for legacy entries (or chart- + * specific zero placeholders) that slipped through without the + * availability flag. + * + * Centralised so the rule lives in one place instead of being copied + * across every chart component, the stats helpers, and the spec + * placeholder renderer. Update here when the definition of "live" + * changes (e.g. add a min sample-size threshold). + */ +export function liveResults(results: ProviderResult[]): ProviderResult[] { + return results.filter( + (r) => r.availability !== "unavailable" && r.ms.p50 > 0, + ); +} diff --git a/src/lib/providers.ts b/src/lib/providers.ts index 02a7d484..a5c9951c 100644 --- a/src/lib/providers.ts +++ b/src/lib/providers.ts @@ -10,6 +10,7 @@ import { cache } from "react"; import { getBenchmarks } from "@/data/benchmarks"; +import { liveResults } from "@/lib/provider-filters"; import type { Benchmark, ProviderResult } from "@/types/benchmark"; export type ProviderAppearance = { @@ -31,9 +32,7 @@ export type ProviderProfile = { }; function rankProviders(b: Benchmark): ProviderResult[] { - const live = b.results.filter( - (r) => r.availability !== "unavailable" && r.ms.p50 > 0, - ); + const live = liveResults(b.results); return [...live].sort((a, c) => b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, ); diff --git a/vercel.json b/vercel.json index 6165ffa5..fdd3e1d1 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,10 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", + "git": { + "deploymentEnabled": { + "main": false + } + }, "crons": [ { "path": "/api/cron/health-check",