fix(site): stabilize date params to break infinite query loop on agents/analytics (#23414)

## Problem

`/agents/analytics` showed an infinite loading spinner. The browser
devtools revealed repeated requests to the chat cost summary endpoint
with `start_date` and `end_date` shifting by a few milliseconds on each
request.

`AgentAnalyticsPage` called `createDateRange(now)` on every render. When
`now` is not passed (production), `createDateRange` falls through to
`dayjs()`, which produces a new millisecond-precision timestamp each
time. Those timestamps became part of the React Query key via
`chatCostSummary()`, so every render created a new query identity, fired
a new fetch, state-updated, re-rendered, and the cycle repeated. The
page never left the loading branch because no query result was ever
observed for the `current` key before it changed.

The same pattern existed in `InsightsContent`, where
`timeRangeToDates()` called `dayjs()` on every render and fed the result
into `prInsights()`.

Storybook didn't catch this because stories pass a fixed `now` prop,
keeping the date range stable.

## Fix

Anchor the date window once using `useState`'s lazy initializer, then
derive `start_date`/`end_date` from the stable anchor during render — no
`useEffect`, no memoization for correctness, just stable input → stable
query key.

- **`AgentAnalyticsPage`**: `const [anchor] = useState<Dayjs>(() =>
dayjs())`, then `createDateRange(now ?? anchor)`. The `now` prop still
takes priority so Storybook snapshots remain deterministic.
- **`InsightsContent`**: Collapses `timeRange` and its anchor into a
single `TimeRangeSelection` state object. A fresh anchor is captured
only when the user changes the selected range (event handler), not on
render. Clicking the already-selected range is a no-op.
This commit is contained in:
Ethan
2026-03-23 18:52:10 +11:00
committed by GitHub
parent 3729ff46fb
commit 0f3d40b97f
2 changed files with 27 additions and 12 deletions
@@ -1,7 +1,7 @@
import { chatCostSummary } from "api/queries/chats";
import { useAuthContext } from "contexts/auth/AuthProvider";
import dayjs, { type Dayjs } from "dayjs";
import type { FC } from "react";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { AgentAnalyticsPageView } from "./AgentAnalyticsPageView";
import { AgentPageHeader } from "./components/AgentPageHeader";
@@ -23,7 +23,8 @@ interface AgentAnalyticsPageProps {
const AgentAnalyticsPage: FC<AgentAnalyticsPageProps> = ({ now }) => {
const { user } = useAuthContext();
const dateRange = createDateRange(now);
const [anchor] = useState<Dayjs>(() => dayjs());
const dateRange = createDateRange(now ?? anchor);
const summaryQuery = useQuery({
...chatCostSummary(user?.id ?? "me", {
@@ -1,28 +1,42 @@
import { prInsights } from "api/queries/chats";
import { Spinner } from "components/Spinner/Spinner";
import dayjs from "dayjs";
import dayjs, { type Dayjs } from "dayjs";
import { type FC, useState } from "react";
import { useQuery } from "react-query";
import { type PRInsightsTimeRange, PRInsightsView } from "./PRInsightsView";
function timeRangeToDates(range: PRInsightsTimeRange) {
const end = dayjs();
type TimeRangeSelection = {
timeRange: PRInsightsTimeRange;
anchor: Dayjs;
};
function timeRangeToDates(range: PRInsightsTimeRange, anchor: Dayjs) {
const days = Number.parseInt(range, 10);
const start = end.subtract(days, "day");
const start = anchor.subtract(days, "day");
return {
start_date: start.toISOString(),
end_date: end.toISOString(),
end_date: anchor.toISOString(),
};
}
export const InsightsContent: FC = () => {
const [timeRange, setTimeRange] = useState<PRInsightsTimeRange>("30d");
const dates = timeRangeToDates(timeRange);
const [selection, setSelection] = useState<TimeRangeSelection>(() => ({
timeRange: "30d",
anchor: dayjs(),
}));
const dates = timeRangeToDates(selection.timeRange, selection.anchor);
const { data, isLoading, error } = useQuery(prInsights(dates));
const handleTimeRangeChange = (range: PRInsightsTimeRange) =>
setTimeRange(range);
const handleTimeRangeChange = (timeRange: PRInsightsTimeRange) =>
setSelection((current) =>
current.timeRange === timeRange
? current
: {
timeRange,
anchor: dayjs(),
},
);
if (isLoading) {
return (
@@ -49,7 +63,7 @@ export const InsightsContent: FC = () => {
return (
<PRInsightsView
data={data}
timeRange={timeRange}
timeRange={selection.timeRange}
onTimeRangeChange={handleTimeRangeChange}
/>
);