diff --git a/components/project/use-anatomy.ts b/components/project/use-anatomy.ts index 852d94ac..233a5de5 100644 --- a/components/project/use-anatomy.ts +++ b/components/project/use-anatomy.ts @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [tick, setTick] = useState(0); + const { data, error, isLoading, mutate } = useSWR( + 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, + }; } diff --git a/package-lock.json b/package-lock.json index 1e1ead33..6ecb393d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 86c232cb..0fbfb141 100644 --- a/package.json +++ b/package.json @@ -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",