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
117 changes: 61 additions & 56 deletions src/components/benchmark-body.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -144,23 +144,30 @@
const searchParams = useSearchParams();
const urlChain = searchParams.get("chain");
const urlRegion = searchParams.get("region");
const urlLayer = searchParams.get("layer");
const resolvedInitialChain =
(urlChain && chainOptions.find((c) => c.value === urlChain)?.value) ?? initialChain;
const resolvedInitialRegion =
(urlRegion && regionOptions.find((r) => r.value === urlRegion)?.value) ?? initialRegion;
const resolvedInitialLayer: ProviderLayer =
urlLayer === "l2" ? "l2" : "l1";

const [chain, setChain] = useState<string | null>(resolvedInitialChain);
const [region, setRegion] = useState<string | null>(resolvedInitialRegion);
const [layer, setLayer] = useState<ProviderLayer>(resolvedInitialLayer);

useEffect(() => {
const url = new URL(window.location.href);
syncParam(url, "chain", chain, chainOptions);
syncParam(url, "region", region, regionOptions);
// Layer param: drop when default ("l1"), keep when user picked l2.
if (layer === "l1") url.searchParams.delete("layer");
else url.searchParams.set("layer", layer);
const next = url.pathname + (url.search ? url.search : "");
if (next !== window.location.pathname + window.location.search) {
window.history.replaceState(null, "", next);
}
}, [chain, region, chainOptions, regionOptions]);
}, [chain, region, layer, chainOptions, regionOptions]);

const fallbackChain = chainOptions[0]?.value ?? null;
const fallbackRegion = regionOptions[0]?.value ?? null;
Expand All@@ -172,9 +179,10 @@
Object.values(variants)[0];
if (!benchmark) return null;

// L1/L2 filter counts, derived from the full unfiltered results so the
// pill counts are stable as the user toggles the filter.
// L1/L2 layer counts. When both > 0 the bench mixes L1 and L2 chains
// and we render a top-level Layer toggle that filters the entire page
// (chart + summary + ledger) to one layer at a time. Default is L1.
const layerCounts = useMemo(() => {

Check failure on line 185 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
let l1 = 0;
let l2 = 0;
for (const r of benchmark.results) {
Expand All@@ -183,33 +191,32 @@
}
return { all: benchmark.results.length, l1, l2 };
}, [benchmark.results]);

// When the bench mixes L1 and L2 chains we render two separate ledger
// tables — one per layer — so the ranking inside each layer reads
// cleanly. Mixing them in a single sort buries Avalanche between
// Blast and Optimism, which is technically correct but unreadable for
// wallet UX decisions ("what does it cost on L1 vs L2"). Keeping the
// full unfiltered benchmark for the chart above.
const hasLayerSplit = layerCounts.l1 > 0 && layerCounts.l2 > 0;
const filterByLayer = (l: ProviderLayer) => ({
...benchmark,
results: benchmark.results.filter((r) => r.layer === l),
});
const l1Benchmark = useMemo(() => filterByLayer("l1"), [benchmark]);
const l2Benchmark = useMemo(() => filterByLayer("l2"), [benchmark]);

const isDraft = benchmark.status === "draft";
// Filter the benchmark to the active layer for the entire page. When
// hasLayerSplit is false the original benchmark is returned untouched
// so non-layer benches keep their existing behavior. The chart, the
// summary stats and the ledger all read from `viewBenchmark`.
const viewBenchmark = useMemo(() => {

Check failure on line 200 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?
if (!hasLayerSplit) return benchmark;
return {
...benchmark,
results: benchmark.results.filter((r) => r.layer === layer),
};
}, [benchmark, hasLayerSplit, layer]);

const isDraft = viewBenchmark.status === "draft";
const { fieldMin, fieldMedian, fieldMax, tailMin, tailMax, tailSpread } =
computeFieldStats(benchmark.results);
computeFieldStats(viewBenchmark.results);

// View switcher state. Per-bench, persisted via localStorage. Default
// mirrors the heuristic the page used before the switcher existed so
// an anonymous user with no prior preference sees the same layout
// they always saw.
const allowedViews = viewsForBenchmark(benchmark);
const defaultView = defaultViewFor(benchmark);
const allowedViews = viewsForBenchmark(viewBenchmark);
const defaultView = defaultViewFor(viewBenchmark);
const [view, setView, viewMounted] = useViewPreference(

Check failure on line 218 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. Did you accidentally call a React Hook after an early return?
benchmark.slug,
viewBenchmark.slug,
defaultView,
allowedViews,
);
Expand All@@ -219,7 +226,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 229 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 toggleExclude = (slug: string) =>
setExcluded((prev) => {
const next = new Set(prev);
Expand All@@ -235,16 +242,16 @@
// 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 245 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?

// Active companion-metric panel. null = main spec metric (default chart
// data, default unit, default header). When a panel id is set, the chart
// pulls its per-provider series from panel.seriesByProvider, swaps the
// header label to panel.label, and the Y-axis unit to panel.unit.
const [activePanelId, setActivePanelId] = useState<string | null>(null);

Check failure on line 251 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 activePanel =
benchmark.metricPanels?.find((p) => p.id === activePanelId) ?? null;
const chartRegionOptions: ChainOption[] = useMemo(

Check failure on line 254 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 All@@ -254,8 +261,19 @@

return (
<>
{(chainOptions.length > 0 || regionOptions.length > 0) && (
{(hasLayerSplit || chainOptions.length > 0 || regionOptions.length > 0) && (
<div className="mt-8 space-y-3">
{hasLayerSplit && (
<DimensionRow
label="Layer"
options={[
{ value: "l1", label: `L1 · ${layerCounts.l1}` },
{ value: "l2", label: `L2 · ${layerCounts.l2}` },
]}
selected={layer}
onSelect={(v) => setLayer(v as ProviderLayer)}
/>
)}
{chainOptions.length > 0 && (
<DimensionRow
label="Chain"
Expand DownExpand Up@@ -340,33 +358,36 @@
>
{view === "countLeaderboard" && (
<CountLeaderboard
benchmark={benchmark}
benchmark={viewBenchmark}
headerActions={<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} />}
/>
)}
{view === "rankedBar" && (
<RankedBarChart
benchmark={benchmark}
benchmark={viewBenchmark}
excluded={excluded}
onToggleExclude={toggleExclude}
onResetExcluded={resetExcluded}
disableTopN={hasLayerSplit}
headerActions={<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} />}
/>
)}
{view === "distribution" && (
<DistributionChart
benchmark={benchmark}
benchmark={viewBenchmark}
excluded={excluded}
onToggleExclude={toggleExclude}
onResetExcluded={resetExcluded}
disableTopN={hasLayerSplit}
headerActions={<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} />}
/>
)}
{view === "donut" && (
<DonutChart
benchmark={benchmark}
benchmark={viewBenchmark}
excluded={excluded}
onToggleExclude={toggleExclude}
disableTopN={hasLayerSplit}
headerActions={<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} />}
/>
)}
Expand All@@ -381,7 +402,7 @@
/>
)}
<TimeSeriesChart
benchmark={benchmark}
benchmark={viewBenchmark}
region={
regionOptions.length > 0
? (region ?? fallbackRegion ?? undefined)
Expand All@@ -392,6 +413,7 @@
excluded={excluded}
onToggleExclude={toggleExclude}
onResetExcluded={resetExcluded}
disableTopN={hasLayerSplit}
headerActions={<ViewSwitcher allowed={allowedViews} value={view} onChange={setView} />}
seriesOverride={activePanel?.seriesByProvider}
metricLabelOverride={activePanel?.label}
Expand All@@ -407,39 +429,22 @@
</div>
</div>

{hasLayerSplit ? (
<>
<div className="mt-8 card-soft rounded-xl p-4 sm:p-6 lg:p-8">
<p className="label-mono text-ink-faint mb-4">
Layer 1 · {layerCounts.l1} chains · sorted by p50
</p>
<LedgerTable benchmark={l1Benchmark} activePanel={activePanel} />
</div>
<div className="mt-8 card-soft rounded-xl p-4 sm:p-6 lg:p-8">
<p className="label-mono text-ink-faint mb-4">
Layer 2 · {layerCounts.l2} chains · sorted by p50
</p>
<LedgerTable benchmark={l2Benchmark} activePanel={activePanel} />
</div>
</>
) : (
<div className="mt-8 card-soft rounded-xl p-4 sm:p-6 lg:p-8">
<p className="label-mono text-ink-faint mb-4">
{benchmark.unit === "count"
? "Product ledger"
: activePanel
? `Product ledger · sorted by ${activePanel.label}`
: "Product ledger · sorted by p50"}
</p>
<LedgerTable benchmark={benchmark} activePanel={activePanel} />
</div>
)}
<div className="mt-8 card-soft rounded-xl p-4 sm:p-6 lg:p-8">
<p className="label-mono text-ink-faint mb-4">
{viewBenchmark.unit === "count"
? "Product ledger"
: activePanel
? `Product ledger · sorted by ${activePanel.label}`
: "Product ledger · sorted by p50"}
</p>
<LedgerTable benchmark={viewBenchmark} activePanel={activePanel} />
</div>

{benchmark.unit !== "count" &&
{viewBenchmark.unit !== "count" &&
Object.keys(benchmark.extras.regions).length > 0 && (
<div className="mt-8 card-soft rounded-xl p-4 sm:p-6 lg:p-8">
<p className="label-mono text-ink-faint mb-4">By region</p>
<RegionGrid benchmark={benchmark} />
<RegionGrid benchmark={viewBenchmark} />
</div>
)}
</>
Expand Down
4 changes: 3 additions & 1 deletion src/components/distribution-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,6 +51,7 @@ export function DistributionChart({
onToggleExclude,
onResetExcluded,
topNControl,
disableTopN,
headerActions,
}: {
benchmark: Benchmark;
Expand All@@ -63,6 +64,7 @@ export function DistributionChart({
* the card corner or eating a footer row of its own. */
headerActions?: ReactNode;
topNControl?: { topN: number | null; setTopN: (n: number | null) => void };
disableTopN?: boolean;
}) {
const { results, unit, higherIsBetter } = benchmark;
const { excluded, toggle, reset } = useChartExclusion(
Expand All@@ -88,7 +90,7 @@ export function DistributionChart({
);
// Top-N selector — shared shape with the other chart views so the
// reader can focus on the top tail without losing the option to widen.
const { topN, setTopN, topNOptions } = useTopN(sortedAll.length, { external: topNControl });
const { topN, setTopN, topNOptions } = useTopN(sortedAll.length, { external: topNControl, disabled: disableTopN });
const sorted = useMemo(
() => (topN == null ? sortedAll : sortedAll.slice(0, topN)),
[sortedAll, topN],
Expand Down
4 changes: 3 additions & 1 deletion src/components/donut-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -39,13 +39,15 @@ export function DonutChart({
excluded: controlledExcluded,
onToggleExclude,
topNControl,
disableTopN,
headerActions,
}: {
benchmark: Benchmark;
excluded?: Set<string>;
onToggleExclude?: (slug: string) => void;
headerActions?: ReactNode;
topNControl?: { topN: number | null; setTopN: (n: number | null) => void };
disableTopN?: boolean;
}) {
const { results } = benchmark;
const { excluded, toggle } = useChartExclusion(
Expand All@@ -60,7 +62,7 @@ export function DonutChart({
// Top-N selector — clip the cohort BEFORE applying exclusion so the
// "top N" semantic matches what every other view shows. Sized off
// the live provider count via the shared `useTopN` hook.
const { topN, setTopN, topNOptions } = useTopN(liveAll.length, { external: topNControl });
const { topN, setTopN, topNOptions } = useTopN(liveAll.length, { external: topNControl, disabled: disableTopN });
const liveClipped = useMemo(
() =>
topN == null
Expand Down
4 changes: 3 additions & 1 deletion src/components/ranked-bar-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,7 @@ type Props = {
* BenchmarkBody passes the <ViewSwitcher> here. */
headerActions?: import("react").ReactNode;
topNControl?: { topN: number | null; setTopN: (n: number | null) => void };
disableTopN?: boolean;
};

export function RankedBarChart({
Expand All@@ -31,6 +32,7 @@ export function RankedBarChart({
onToggleExclude,
onResetExcluded,
topNControl,
disableTopN,
headerActions,
}: Props) {
const { excluded, toggle, reset } = useChartExclusion(
Expand DownExpand Up@@ -77,7 +79,7 @@ export function RankedBarChart({
// the headline metric (`allRows`), via the shared `useTopN` hook so
// every chart view (ranked bar, time series, distribution, donut)
// agrees on the option set and the empty-toolbar rule.
const { topN, setTopN, topNOptions } = useTopN(allRows.length, { external: topNControl });
const { topN, setTopN, topNOptions } = useTopN(allRows.length, { external: topNControl, disabled: disableTopN });
const rows = useMemo(() => {
if (topN == null) return allRows;
return allRows.slice(0, topN);
Expand Down
4 changes: 3 additions & 1 deletion src/components/time-series-chart.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,6 +35,7 @@ type Props = {
metricLabelOverride?: string;
unitOverride?: Benchmark["unit"];
topNControl?: { topN: number | null; setTopN: (n: number | null) => void };
disableTopN?: boolean;
};

type Range = "1h" | "6h" | "24h" | "7d" | "30d";
Expand DownExpand Up@@ -74,6 +75,7 @@ export function TimeSeriesChart({
unitOverride,
onResetExcluded,
topNControl,
disableTopN,
headerActions,
}: Props) {
const [range, setRange] = useState<Range>("24h");
Expand DownExpand Up@@ -159,7 +161,7 @@ export function TimeSeriesChart({
// Top-N selector — sized off the post-filter line count via the
// shared `useTopN` hook so the option set agrees across every
// chart view on the bench page.
const { topN, setTopN, topNOptions } = useTopN(allLines.length, { external: topNControl });
const { topN, setTopN, topNOptions } = useTopN(allLines.length, { external: topNControl, disabled: disableTopN });
const lines = useMemo(() => {
if (topN == null) return allLines;
return allLines.slice(0, topN);
Expand Down
Loading