fix(ui): adopt SWR for useAnatomy to deduplicate requests across components and fix API flooding

This commit is contained in:
2026-05-17 20:18:19 -07:00
parent 6b8862ef2b
commit 331312b648
3 changed files with 73 additions and 64 deletions

View File

@@ -4,9 +4,14 @@
* Single-fetch anatomy hook shared by the Product / Hosting tabs.
* Hardened against silent failure: 10s timeout, error surfacing, and
* graceful unmount.
*
* Uses SWR to deduplicate requests across components (ProjectStagePill,
* ProjectHeaderUrls, Hosting page) so they share a single network request
* instead of hammering the API independently.
*/
import { useEffect, useState } from "react";
import useSWR from "swr";
import { useCallback } from "react";
export interface Anatomy {
project: {
@@ -114,73 +119,62 @@ export interface UseAnatomyOptions {
pollMs?: number;
}
const fetcher = async (url: string) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(url, {
credentials: "include",
signal: controller.signal,
cache: "no-store",
});
let body: unknown = {};
try {
body = await res.json();
} catch {
/* keep {} */
}
if (!res.ok) {
const msg =
(body as { error?: string }).error ||
`HTTP ${res.status} ${res.statusText}`.trim();
throw new Error(msg);
}
return body as Anatomy;
} catch (err: any) {
if (err.name === "AbortError") {
throw new Error("Request timed out after 10s.");
}
throw err;
} finally {
clearTimeout(timeout);
}
};
export function useAnatomy(
projectId: string,
options: UseAnatomyOptions = {},
): UseAnatomyResult {
const { pollMs } = options;
const [anatomy, setAnatomy] = useState<Anatomy | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const { data, error, isLoading, mutate } = useSWR<Anatomy, Error>(
projectId ? `/api/projects/${projectId}/anatomy` : null,
fetcher,
{
refreshInterval:
options.pollMs && options.pollMs > 0 ? options.pollMs : 0,
dedupingInterval: 2000,
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
);
// Background poll. We bump `tick` on an interval, which re-runs the
// fetch effect below. Skipping the timer entirely when pollMs is
// zero/undefined keeps the default render path identical to before.
useEffect(() => {
if (!pollMs || pollMs <= 0) return;
const id = setInterval(() => setTick((t) => t + 1), pollMs);
return () => clearInterval(id);
}, [pollMs]);
const reload = useCallback(() => {
mutate();
}, [mutate]);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
setLoading(true);
setError(null);
fetch(`/api/projects/${projectId}/anatomy`, {
credentials: "include",
signal: controller.signal,
cache: "no-store",
})
.then(async (r) => {
let body: unknown = {};
try {
body = await r.json();
} catch {
/* keep {} */
}
if (!r.ok) {
const msg =
(body as { error?: string }).error ||
`HTTP ${r.status} ${r.statusText}`.trim();
throw new Error(msg);
}
return body as Anatomy;
})
.then((data) => {
if (!cancelled) setAnatomy(data);
})
.catch((err) => {
if (cancelled) return;
if (err?.name === "AbortError")
setError("Request timed out after 10s.");
else setError(err?.message || "Failed to load project anatomy");
})
.finally(() => {
clearTimeout(timeout);
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
controller.abort();
clearTimeout(timeout);
};
}, [projectId, tick]);
return { anatomy, loading, error, reload: () => setTick((t) => t + 1) };
return {
anatomy: data ?? null,
loading: isLoading,
error: error?.message ?? null,
reload,
};
}

View File

@@ -52,6 +52,7 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"ssh2": "^1.17.0",
"swr": "^2.4.1",
"tailwind-merge": "^3.4.0",
"tsx": "^4.20.6",
"uuid": "^13.0.0",
@@ -18127,6 +18128,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swr": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz",

View File

@@ -68,6 +68,7 @@
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"ssh2": "^1.17.0",
"swr": "^2.4.1",
"tailwind-merge": "^3.4.0",
"tsx": "^4.20.6",
"uuid": "^13.0.0",