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
48 changes: 38 additions & 10 deletions src/app/api/series/[slug]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,33 @@ const getSeriesMapCached = unstable_cache(
region: string | undefined,
kind: string | undefined,
venue: string | undefined,
panelId: string | undefined,
): Promise<Record<string, (number | null)[]> | null> => {
const sig = filterSig({ chain, region, kind, venue });
// Try CDN blob first (Phase 3), fall back to Redis via SRH.
const stored =
(await loadSnapshotFromBlob(slug, sig)) ??
(await readMaterialized(slug, sig));
if (stored) {
const fromBlob =
range === "7d"
? stored.bench.extras.series7d
: stored.bench.extras.series30d;
if (fromBlob && Object.keys(fromBlob).length > 0) return fromBlob;
// Panel-scoped lookup: returns the companion-metric series for a
// specific metricPanel (?panel=<id>), not the main bench series.
// Without this, panels have no way to render 7d/30d — the
// slimBenchmarkForCache strip drops panel long-range series from
// the payload and the chart falls back to a disabled tab.
if (panelId) {
const panel = stored.bench.metricPanels?.find((p) => p.id === panelId);
const fromPanel =
range === "7d"
? panel?.seriesByProvider7d
: panel?.seriesByProvider30d;
if (fromPanel && Object.keys(fromPanel).length > 0) return fromPanel;
} else {
const fromBlob =
range === "7d"
? stored.bench.extras.series7d
: stored.bench.extras.series30d;
if (fromBlob && Object.keys(fromBlob).length > 0) return fromBlob;
}
}
// Fallback: blob missing (newly deployed bench) or empty for this
// variant. Run the live build to seed something; the worker will
Expand All @@ -51,12 +66,18 @@ const getSeriesMapCached = unstable_cache(
const spec = specs.find((s) => s.slug === slug);
if (!spec || spec.status !== "live") return null;
const b = await specToBenchmark(spec, { chain, region, kind, venue });
if (panelId) {
const panel = b.metricPanels?.find((p) => p.id === panelId);
return (range === "7d"
? panel?.seriesByProvider7d
: panel?.seriesByProvider30d) ?? null;
}
return (range === "7d" ? b.extras.series7d : b.extras.series30d) ?? null;
},
// v4: dense series with explicit nulls for empty Prom buckets. v3
// entries hold the old hole-compressed arrays whose length no longer
// matches the dense timestamp grid emitted below.
["series-by-range-v4"],
// v5: added ?panel=<id> variant for companion-metric long-range series.
// v4 keys omit the panelId slot; keep the version bumped so old cache
// entries don't collide with the new signature.
["series-by-range-v5"],
{ revalidate: 300, tags: ["benchmarks"] },
);

Expand Down Expand Up @@ -112,6 +133,7 @@ export async function GET(
const allowedSlugs = providersFilter
? new Set(providersFilter.split(",").map((s) => s.trim()).filter(Boolean))
: null;
const panelId = url.searchParams.get("panel")?.trim() || undefined;

// Honor the same dimensional filters the bench page itself supports
// (?chain=ethereum, ?region=eu-west, ?kind=..., ?venue=...). Without
Expand Down Expand Up @@ -169,12 +191,18 @@ export async function GET(
filters.region,
filters.kind,
filters.venue,
panelId,
),
hasFilters ? getBenchmark(slug, filters) : Promise.resolve(aggregate),
]);
} else {
bench = hasFilters ? await getBenchmark(slug, filters) : aggregate;
seriesMap = bench?.extras.series24h;
if (panelId) {
const panel = bench?.metricPanels?.find((p) => p.id === panelId);
seriesMap = panel?.seriesByProvider;
} else {
seriesMap = bench?.extras.series24h;
}
}
const b = bench ?? aggregate;

Expand Down
1 change: 1 addition & 0 deletions src/components/benchmark-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@
// not only on the timeseries view. Providers the panel has no value
// for (book could not fill the tier) drop out of the ranking, which
// is the skipped-not-extrapolated rule made visible.
const panelViewBenchmark = useMemo(() => {

Check failure on line 562 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 (!activePanel) return viewBenchmark;
const vals = activePanel.values ?? {};
return {
Expand Down Expand Up @@ -811,6 +811,7 @@
seriesOverride={activePanel?.seriesByProvider}
seriesOverride7d={activePanel?.seriesByProvider7d}
seriesOverride30d={activePanel?.seriesByProvider30d}
activePanelId={activePanel?.id ?? null}
metricLabelOverride={activePanel?.label}
unitOverride={activePanel?.unit}
higherIsBetterOverride={activePanel?.higherIsBetter}
Expand Down
112 changes: 104 additions & 8 deletions src/components/time-series-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@
* picks the matching one when the range tab is 7d or 30d. */
seriesOverride7d?: Record<string, (number | null)[]>;
seriesOverride30d?: Record<string, (number | null)[]>;
/** Panel id that produced the seriesOverride*. When set (and no
* seriesOverride7d/30d were passed inline — the common case since
* slimBenchmarkForCache strips those to keep cache entries small),
* the chart lazy-fetches /api/series?panel=<id>&range=7d|30d and
* drives the long-range tabs from the response. Without this, 7d
* and 30d pills stayed disabled whenever a metric panel was active
* even though the underlying Prom data existed. */
activePanelId?: string | null;
metricLabelOverride?: string;
unitOverride?: Benchmark["unit"];
/** Direction override for ranking when a metric panel is active. Bench
Expand Down Expand Up @@ -92,6 +100,7 @@
seriesOverride,
seriesOverride7d,
seriesOverride30d,
activePanelId,
metricLabelOverride,
unitOverride,
higherIsBetterOverride,
Expand Down Expand Up @@ -142,6 +151,10 @@
// most one fan-out per (bench, range) per minute.
const [lazySeries7d, setLazySeries7d] = useState<Record<string, (number | null)[]> | null>(null);
const [lazySeries30d, setLazySeries30d] = useState<Record<string, (number | null)[]> | null>(null);
// Per-panel lazy 7d/30d, keyed by panel id. Cached across a panel
// switch so flipping back to a previously-viewed panel is instant.
const [lazyPanel7d, setLazyPanel7d] = useState<Record<string, Record<string, (number | null)[]>>>({});
const [lazyPanel30d, setLazyPanel30d] = useState<Record<string, Record<string, (number | null)[]>>>({});

// Pre-fetch 7d AND 30d in the background as soon as the chart mounts,
// not just when the user clicks the tab. The fetches are non-blocking
Expand Down Expand Up @@ -201,19 +214,92 @@
};
}, [regionProp, chainProp, benchmark.slug]);

// Panel-scoped lazy fetch. Runs when the active panel id changes and the
// parent did NOT ship the panel's own 7d/30d series inline (the common
// case since slimBenchmarkForCache drops them). Cache-keyed per panel so
// switching between panels re-uses previously loaded data instantly.
useEffect(() => {
if (!activePanelId) return;
// Inline overrides win — skip the fetch when the parent already
// supplied panel long-range series (e.g. an unshimmed test caller).
if (seriesOverride7d && seriesOverride30d) return;
if (lazyPanel7d[activePanelId] && lazyPanel30d[activePanelId]) return;
let cancelled = false;
const need7d = !seriesOverride7d && !lazyPanel7d[activePanelId];
const need30d = !seriesOverride30d && !lazyPanel30d[activePanelId];
const done: Record<"7d" | "30d", boolean> = {
"7d": !need7d,
"30d": !need30d,
};
const buildQs = (range: "7d" | "30d") => {
const qs = new URLSearchParams({ range, panel: activePanelId });
if (regionProp && regionProp !== "all") qs.set("region", regionProp);
if (chainProp && chainProp !== "all") qs.set("chain", chainProp);
return qs.toString();
};
const fetchOne = (range: "7d" | "30d", attempt = 0) => {
if (cancelled || done[range]) return;
fetch(`/api/series/${benchmark.slug}?${buildQs(range)}`)
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
.then((data: { providers: { slug: string; values: (number | null)[] }[] }) => {
if (cancelled) return;
done[range] = true;
const map: Record<string, (number | null)[]> = {};
for (const p of data.providers) map[p.slug] = p.values;
if (range === "7d") {
setLazyPanel7d((prev) => ({ ...prev, [activePanelId]: map }));
} else {
setLazyPanel30d((prev) => ({ ...prev, [activePanelId]: map }));
}
})
.catch(() => {
if (cancelled) return;
if (attempt < 2) {
setTimeout(() => fetchOne(range, attempt + 1), (attempt + 1) * 2000);
} else {
done[range] = true;
if (range === "7d") {
setLazyPanel7d((prev) => ({ ...prev, [activePanelId]: {} }));
} else {
setLazyPanel30d((prev) => ({ ...prev, [activePanelId]: {} }));
}
}
});
};
if (need7d) fetchOne("7d");
if (need30d) fetchOne("30d");
return () => {
cancelled = true;
};
// Deliberately excluding lazyPanel7d/30d from deps — the effect reads
// them for its "already cached?" guard, but re-triggering when the
// maps mutate would deadloop with the setState calls inside.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activePanelId, regionProp, chainProp, benchmark.slug, seriesOverride7d, seriesOverride30d]);

// Tab availability: 24h is always present (served from the cached
// Benchmark), 7d / 30d are always offered as tabs since they're
// lazy-fetchable. The fetch resolves to {} on no-data, which we still
// treat as "tab available" because the chart can render an empty
// state inline rather than hide the tab.
//
// Exception: when a metric panel is active (seriesOverride set), the
// panel's own 7d / 30d series are no longer cached either, so 7d /
// 30d tabs would show 24h data sliced wrong. Disable them in that
// case — readers must deactivate the panel to see longer ranges.
// Metric-panel scope: when a panel is active, the tab is enabled if
// (a) the parent shipped seriesOverride7d/30d inline, OR
// (b) we have (or are about to have) lazy-fetched panel series via
// activePanelId. Panels without activePanelId still fall back to
// the old behavior (disabled long-range tabs) so untouched
// callers keep working.
const panelActive = !!seriesOverride;
const has7d = !panelActive || !!seriesOverride7d;
const has30d = !panelActive || !!seriesOverride30d;
const panelLazy7d = activePanelId ? lazyPanel7d[activePanelId] : undefined;
const panelLazy30d = activePanelId ? lazyPanel30d[activePanelId] : undefined;
const has7d =
!panelActive ||
!!seriesOverride7d ||
(!!activePanelId && panelLazy7d !== undefined);
const has30d =
!panelActive ||
!!seriesOverride30d ||
(!!activePanelId && panelLazy30d !== undefined);

const availableRegions = useMemo(() => {
const set = new Set<string>();
Expand Down Expand Up @@ -245,8 +331,18 @@
// variant trailing edge. Long range tabs fall back to the 24h
// variant when the longer one is missing (older specs).
const pickPanel = (): Record<string, (number | null)[]> | undefined => {
if (range === "30d" && seriesOverride30d) return seriesOverride30d;
if (range === "7d" && seriesOverride7d) return seriesOverride7d;
// Prefer inline overrides (present when parent chose to ship panel
// long-range series with the initial payload), then lazy-fetched
// panel maps (the common path — long-range series are stripped
// from the cached bench and loaded via /api/series?panel=<id>),
// then fall back to the 24h override so the chart still renders
// something instead of an empty pane.
if (range === "30d") {
return seriesOverride30d ?? panelLazy30d ?? seriesOverride;
}
if (range === "7d") {
return seriesOverride7d ?? panelLazy7d ?? seriesOverride;
}
return seriesOverride;
};
const panel = pickPanel();
Expand Down Expand Up @@ -295,7 +391,7 @@
return higherIsBetter ? bv - av : av - bv;
});
return built;
}, [benchmark, range, region, colors, excluded, seriesOverride, seriesOverride7d, seriesOverride30d, higherIsBetterOverride, lazySeries7d, lazySeries30d, isLongRange, longRangeSeries]);

Check warning on line 394 in src/components/time-series-chart/index.tsx

View workflow job for this annotation

GitHub Actions / check

React Hook useMemo has missing dependencies: 'panelLazy30d' and 'panelLazy7d'. Either include them or remove the dependency array

// Top-N selector — sized off the post-filter line count via the
// shared `useTopN` hook so the option set agrees across every
Expand Down
Loading