From 0f3d40b97f414876d55bf569e9937b6c9ba5500c Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Mon, 23 Mar 2026 18:52:10 +1100 Subject: [PATCH] fix(site): stabilize date params to break infinite query loop on `agents/analytics` (#23414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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())`, 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. --- .../pages/AgentsPage/AgentAnalyticsPage.tsx | 5 +-- .../AgentsPage/components/InsightsContent.tsx | 34 +++++++++++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentAnalyticsPage.tsx b/site/src/pages/AgentsPage/AgentAnalyticsPage.tsx index a7c1930c0a..e5742acafb 100644 --- a/site/src/pages/AgentsPage/AgentAnalyticsPage.tsx +++ b/site/src/pages/AgentsPage/AgentAnalyticsPage.tsx @@ -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 = ({ now }) => { const { user } = useAuthContext(); - const dateRange = createDateRange(now); + const [anchor] = useState(() => dayjs()); + const dateRange = createDateRange(now ?? anchor); const summaryQuery = useQuery({ ...chatCostSummary(user?.id ?? "me", { diff --git a/site/src/pages/AgentsPage/components/InsightsContent.tsx b/site/src/pages/AgentsPage/components/InsightsContent.tsx index 20ec53f5fa..618b891942 100644 --- a/site/src/pages/AgentsPage/components/InsightsContent.tsx +++ b/site/src/pages/AgentsPage/components/InsightsContent.tsx @@ -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("30d"); - const dates = timeRangeToDates(timeRange); + const [selection, setSelection] = useState(() => ({ + 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 ( );