mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user