Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/benchmark-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -70,7 +71,7 @@

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
Expand Down Expand Up @@ -179,7 +180,7 @@
// they always saw.
const allowedViews = viewsForBenchmark(benchmark);
const defaultView = defaultViewFor(benchmark);
const [view, setView, viewMounted] = useViewPreference(

Check failure on line 183 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook "useViewPreference" is called conditionally. React Hooks must be called in the exact same order in every component render
benchmark.slug,
defaultView,
allowedViews,
Expand All @@ -190,7 +191,7 @@
// hidden when they switch to distribution or donut - the model is
// "this is the field of providers the reader chose to focus on",
// not "what each view chose to drop". Resets on bench navigation.
const [excluded, setExcluded] = useState<Set<string>>(() => new Set());

Check failure on line 194 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render
const toggleExclude = (slug: string) =>
setExcluded((prev) => {
const next = new Set(prev);
Expand All @@ -206,8 +207,8 @@
// being split between the dimension row and the chart toolbar.
const chartRegions = chartOnlyRegions(benchmark);
const showChartRegionRow = regionOptions.length === 0 && chartRegions.length > 1;
const [chartRegion, setChartRegion] = useState<string>("all");

Check failure on line 210 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
const chartRegionOptions: ChainOption[] = useMemo(

Check failure on line 211 in src/components/benchmark-body.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook "useMemo" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?
() => [
{ value: "all", label: "All" },
...chartRegions.map((r) => ({ value: r, label: REGION_DISPLAY[r] ?? r })),
Expand Down
7 changes: 4 additions & 3 deletions src/components/chain-headings-summary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Benchmark } from "@/types/benchmark";
import { liveResults } from "@/lib/provider-filters";
import { fmtUnit } from "@/lib/format";

/**
Expand All @@ -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
Expand All @@ -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
);

Expand Down
4 changes: 3 additions & 1 deletion src/components/count-leaderboard.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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];
Expand Down
26 changes: 7 additions & 19 deletions src/components/distribution-chart.tsx
Original file line number Diff line number Diff line change
@@ -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 /
Expand All @@ -32,28 +34,14 @@ export function DistributionChart({
onToggleExclude?: (slug: string) => void;
}) {
const { results, unit, higherIsBetter } = benchmark;
const [internalExcluded, setInternalExcluded] = useState<Set<string>>(
() => 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 <p className="text-[12px] text-ink-faint py-8 text-center">No data.</p>;
}
Expand Down
22 changes: 6 additions & 16 deletions src/components/donut-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,25 +42,13 @@ export function DonutChart({
onToggleExclude?: (slug: string) => void;
}) {
const { results } = benchmark;
const [internalExcluded, setInternalExcluded] = useState<Set<string>>(
() => 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));
Expand Down
4 changes: 3 additions & 1 deletion src/components/ledger-table.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/components/mini-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import type { Benchmark } from "@/types/benchmark";
import { buildProviderColors } from "@/lib/series-colors";

Expand Down Expand Up @@ -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
Expand Down
29 changes: 6 additions & 23 deletions src/components/ranked-bar-chart.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -44,29 +45,11 @@ export function RankedBarChart({
onToggleExclude,
onResetExcluded,
}: Props) {
const [internalExcluded, setInternalExcluded] = useState<Set<string>>(
() => 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),
Expand Down
30 changes: 19 additions & 11 deletions src/components/region-grid.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, number>();
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<string, number>();
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 (
<div className="border-y-2 border-ink py-2 overflow-x-auto -mx-4 sm:mx-0 px-4 sm:px-0">
Expand Down
54 changes: 54 additions & 0 deletions src/hooks/use-chart-exclusion.ts
Original file line number Diff line number Diff line change
@@ -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<string>`
* 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<string>,
onToggle?: (slug: string) => void,
onReset?: () => void,
): {
excluded: Set<string>;
toggle: (slug: string) => void;
reset: () => void;
} {
const [internal, setInternal] = useState<Set<string>>(() => 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 };
}
9 changes: 5 additions & 4 deletions src/lib/bench-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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];
Expand All @@ -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;
Expand All @@ -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;
}
Expand Down
5 changes: 3 additions & 2 deletions src/lib/citation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading