Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ export const ServicesTab = () => {
() => sortServicesByPublicFirst(servicesData.services),
[servicesData.services],
);
const loading = servicesData.isLoading || containersLoading;
// Skeleton only when there is nothing to show. containersLoading flips on
// every refetch (and every remount of this tab), so OR-ing it raw flashed
// the full-tab skeleton on every action and tab switch (#666) — rows render
// fine without container data (status falls back per service).
const loading =
servicesData.isLoading || (containersLoading && services.length === 0);
const projectSlugBase = projectData.slug || projectData.name || "project";
const selectedId = slug?.[1] ?? null;
const hasProjectId = Boolean(id && id !== "undefined");
Expand Down Expand Up @@ -226,7 +231,12 @@ export const ServicesTab = () => {
}

/* ── Error state ───────────────────────────────────────────────── */
if (error || servicesData.error) {
// Full-tab error only when there is nothing to show. A failed refetch with
// rows on screen keeps them and reports the failure inline — blanking a
// working list on a transient 5xx was the same complaint as the skeleton
// flash (#666).
const failure = error || servicesData.error;
if (failure && services.length === 0) {
return (
<div className="bg-card rounded-2xl border border-border/50 p-8 text-center">
<AlertCircle className="size-8 text-danger mx-auto mb-3" />
Expand Down Expand Up @@ -524,6 +534,19 @@ export const ServicesTab = () => {
</div>
)}

{failure && (
<div className="flex items-center gap-2 rounded-xl border border-danger/30 bg-danger/[0.06] px-3 py-2 text-xs text-danger">
<AlertCircle className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1">{failure}</span>
<button
onClick={fetchData}
className="font-medium underline underline-offset-2"
>
{t.projects.services.retry}
</button>
</div>
)}

<div className="bg-card rounded-2xl border border-border/50 divide-y divide-border/30 overflow-hidden">
{services.map((svc) => {
const ct = containerFor(svc.id);
Expand Down
34 changes: 20 additions & 14 deletions apps/dashboard/src/context/ProjectSettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { projectsApi, servicesApi, type Service } from "@/lib/api";
import { PROJECT_INFO_NOT_FOUND, useProjectInfo } from "@/hooks/useProjectEndpoints";
import type { ActiveMigration } from "@/utils/project-status";
import { dedupeServerLogs } from "./server-log-dedup";
import { beginServicesFetch, failServicesFetch } from "./services-fetch-state";

interface ProjectDomain {
domain: string;
Expand Down Expand Up @@ -730,11 +731,14 @@ export const ProjectSettingsProvider: React.FC<ProviderProps> = ({
null,
);
const servicesRequestIdRef = useRef(0);
/** Which project the list in `servicesData` belongs to. null = holds nothing. */
const servicesLoadedIdRef = useRef<string | null>(null);

const refreshServices = useCallback(async () => {
if (!id || id === "undefined") {
servicesRequestIdRef.current += 1;
servicesRequestRef.current = null;
servicesLoadedIdRef.current = null;
setServicesData({ services: [], isLoading: false, error: null });
return [];
}
Expand All @@ -748,28 +752,30 @@ export const ProjectSettingsProvider: React.FC<ProviderProps> = ({

let promise!: Promise<Service[]>;
promise = (async () => {
setServicesData((prev) => ({ ...prev, isLoading: true, error: null }));
const loadedId = servicesLoadedIdRef.current;
setServicesData((prev) => beginServicesFetch(prev, loadedId, id));

const fail = () => {
if (servicesRequestIdRef.current !== requestId) return;
setServicesData((prev) => failServicesFetch(prev, loadedId, id));
if (loadedId !== id) servicesLoadedIdRef.current = null;
};

try {
const response = await servicesApi.list(id);
const services = response.success ? (response.services ?? []) : [];
if (!response.success) {
fail();
return [];
}
const services = response.services ?? [];
if (servicesRequestIdRef.current === requestId) {
setServicesData({
services,
isLoading: false,
error: response.success ? null : "Failed to load services",
});
servicesLoadedIdRef.current = id;
setServicesData({ services, isLoading: false, error: null });
}
return services;
} catch (error) {
console.error("Failed to fetch project services:", error);
if (servicesRequestIdRef.current === requestId) {
setServicesData({
services: [],
isLoading: false,
error: "Failed to load services",
});
}
fail();
return [];
} finally {
if (servicesRequestRef.current?.promise === promise) {
Expand Down
53 changes: 53 additions & 0 deletions apps/dashboard/src/context/services-fetch-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import type { Service } from "@/lib/api";
import { beginServicesFetch, failServicesFetch } from "./services-fetch-state";

/**
* The #666 regression and its inverse both live here: a refresh that reports
* loading (skeleton flash on every action), and a navigation that doesn't
* (project A's rows rendered under project B). The fix must satisfy both.
*/

const rows = [{ id: "s1" }] as unknown as Service[];
const prev = { services: rows, isLoading: false, error: null };

describe("beginServicesFetch", () => {
it("refresh keeps the rows on screen and reports no loading", () => {
expect(beginServicesFetch(prev, "A", "A")).toMatchObject({
isLoading: false,
services: rows,
});
});

it("navigation reports loading even though the held list is non-empty", () => {
// A's rows must never pass for B's — the skeleton is correct here.
expect(beginServicesFetch(prev, "A", "B").isLoading).toBe(true);
});

it("first load with nothing held reports loading", () => {
expect(
beginServicesFetch({ services: [], isLoading: false, error: null }, null, "A").isLoading,
).toBe(true);
});

it("clears a previous error once a fetch starts", () => {
const failing = { ...prev, error: "Failed to load services" };
expect(beginServicesFetch(failing, "A", "A").error).toBeNull();
});
});

describe("failServicesFetch", () => {
it("failed refresh keeps the rows that were on screen", () => {
expect(failServicesFetch(prev, "A", "A").services).toBe(rows);
});

it("failed navigation drops the previous project's list", () => {
expect(failServicesFetch(prev, "A", "B").services).toEqual([]);
});

it("always clears loading and surfaces the failure", () => {
const out = failServicesFetch({ ...prev, isLoading: true }, "A", "A");
expect(out.isLoading).toBe(false);
expect(out.error).toBe("Failed to load services");
});
});
40 changes: 40 additions & 0 deletions apps/dashboard/src/context/services-fetch-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Service } from "@/lib/api";

export interface ServicesFetchState {
services: Service[];
isLoading: boolean;
error: string | null;
}

/**
* Whether a services read has anything on screen worth keeping cannot be read off
* the list — it depends on whether that list belongs to the id being fetched.
* `beginFetchState` owns the full reasoning (and the infinite-loop regression it
* exists to prevent); this is the same rule for the services slice, whose shape is
* `services`, not `data`.
*
* Same id is a REFRESH: keep the rows, report no loading — flipping it collapsed
* the tab into its skeleton on every action and tab switch (#666).
* Different id is a NAVIGATION: there is nothing of this project's to show, and
* calling it loaded would render project A's services under project B.
*/
export function beginServicesFetch(
prev: ServicesFetchState,
loadedId: string | null,
id: string,
): ServicesFetchState {
return { ...prev, isLoading: loadedId !== id, error: null };
}

/** A failed REFRESH keeps its rows; a failed NAVIGATION must not leave the previous project's. */
export function failServicesFetch(
prev: ServicesFetchState,
loadedId: string | null,
id: string,
): ServicesFetchState {
return {
services: loadedId === id ? prev.services : [],
isLoading: false,
error: "Failed to load services",
};
}