fix(ui): adopt SWR for useAnatomy to deduplicate requests across components and fix API flooding
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
14
vibn-frontend/package-lock.json
generated
14
vibn-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user