Skip to content
Closed
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 @@ -32,7 +32,11 @@ import { DraftProjectView } from "../components/DraftProjectView";
import { environmentErrorMessage, environmentWizardHref } from "../components/environment-next";
import { getProjectStatus } from "@/utils/project-status";
import { useProjectSettings } from "@/context/ProjectSettingsContext";
import { useProjectInfo, PROJECT_INFO_NOT_FOUND } from "@/hooks/useProjectEndpoints";
import {
invalidateProjectCaches,
useProjectInfo,
PROJECT_INFO_NOT_FOUND,
} from "@/hooks/useProjectEndpoints";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useToast } from "@/context/ToastContext";
Expand Down Expand Up @@ -598,6 +602,22 @@ const ProjectSettingsContent = () => {
// Optimistic - immediately show "Deleting" status
setProjectData((prev: any) => ({ ...prev, deletedAt: new Date().toISOString() }));

// #657: teardown removed THIS environment, but every sibling's cached
// /info bundle still lists it — their switchers would keep showing the
// dead entry until a hard reload. Drop all known ids on the way out.
// Only invoked on success; a failed delete keeps every cache as-is.
// Accepted cost: invalidateProjectCaches evicts overview/geo/usage too,
// per id — pure eviction for unmounted siblings (only mounted hooks
// refetch), so no request storm; just a slower first paint next visit.
const invalidateAfterTeardown = () => {
const ids = new Set<string>([projectData.id]);
for (const env of environments ?? []) {
const eid = String(env?.id ?? "");
if (eid) ids.add(eid);
}
ids.forEach((eid) => invalidateProjectCaches(eid));
};

try {
const response = await projectsApi.delete(projectData.id, {
wipeVolumes,
Expand Down Expand Up @@ -651,6 +671,7 @@ const ProjectSettingsContent = () => {
);
}
invalidateSidebarNavCounts();
invalidateAfterTeardown();
router.push("/");
return;
}
Expand All @@ -667,6 +688,7 @@ const ProjectSettingsContent = () => {
t.projects.delete.partialCleanupTitle,
);
invalidateSidebarNavCounts();
invalidateAfterTeardown();
router.push("/");
return;
}
Expand Down Expand Up @@ -784,10 +806,14 @@ const ProjectSettingsContent = () => {
return;
}

// 404: someone else already deleted the project in another tab.
// 404: someone else already deleted the project in another tab. Not a
// failure — the row IS gone — so this is a third success exit, and the
// one where this tab's cached bundles are GUARANTEED stale rather than
// merely possibly (#657).
if (err instanceof ApiError && err.status === 404) {
showToast(t.projects.delete.alreadyDeleted, "success");
invalidateSidebarNavCounts();
invalidateAfterTeardown();
router.push("/");
return;
}
Expand Down
19 changes: 17 additions & 2 deletions apps/dashboard/src/context/ProjectSettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { useRouter } from "next/navigation";
import { useI18n } from "@/components/i18n-provider";
import { usePlatform } from "@/context/PlatformContext";
import { projectsApi, servicesApi, type Service } from "@/lib/api";
import { PROJECT_INFO_NOT_FOUND, useProjectInfo } from "@/hooks/useProjectEndpoints";
import {
invalidateProjectCaches,
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";
Expand Down Expand Up @@ -809,10 +813,21 @@ export const ProjectSettingsProvider: React.FC<ProviderProps> = ({
if (!response.success || !response.data) {
throw new Error(response.error || "Failed to create environment");
}
// #657: the mutation changed the environments list — which is bundled
// into EVERY environment's /info payload. Drop those caches NOW or the
// next projectInfo emit re-runs the effect above and writes the stale
// bundle back over refreshEnvironments' fresh patch, and the new
// environment vanishes until a hard reload. Self first, then the known
// siblings whose cached bundles are missing the new entry.
invalidateProjectCaches(id);
for (const env of environments) {
const eid = String(env?.id ?? "");
if (eid && eid !== String(id)) invalidateProjectCaches(eid);
}
await refreshEnvironments();
return response.data as ProjectEnvironment;
},
[id, refreshEnvironments],
[id, refreshEnvironments, environments],
);

// Terminal Logs Management
Expand Down
92 changes: 92 additions & 0 deletions apps/dashboard/src/context/project-settings-environments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

/**
* Where the environments list is written, and why every environment mutation
* must also drop the project caches (#657).
*
* The `environments` state in ProjectSettingsContext has TWO writers: the
* effect that re-seeds it from the bundled /info payload whenever
* `projectInfo` changes, and `refreshEnvironments()` which patches it from the
* dedicated list endpoint. A create/delete that only patches the second one
* loses: the module-level infoCache still holds the OLD bundle, so the next
* `projectInfo` emit re-runs the effect and writes the stale list back over
* the fresh one — the new environment vanishes (or the deleted one lingers)
* until a hard reload.
*
* The list ships inside EVERY environment's /info payload, so siblings'
* cached bundles go stale too — hence invalidating known sibling ids, not
* just the current one.
*
* The fix is wiring, not logic — invisible to a typecheck and to a static
* render, and there is no jsdom harness for this provider — so like
* advanced-migration-session.test.ts it is pinned here in source.
*/
const here = dirname(fileURLToPath(import.meta.url));
const context = readFileSync(join(here, "./ProjectSettingsContext.tsx"), "utf8");
const page = readFileSync(
join(here, "../app/(dashboard)/projects/[id]/[[...slug]]/page.tsx"),
"utf8",
);

describe("creating an environment survives the next projectInfo emit", () => {
// Anchored on the return, not a byte count: everything up to it is the
// mutation's success path, and a comment added above cannot slide it.
const createEnvironment = context.slice(context.indexOf("const createEnvironment = useCallback"));
const body = createEnvironment.slice(0, createEnvironment.indexOf("return response.data"));

it("drops the stale info cache instead of only patching the derived copy", () => {
expect(body).toContain("invalidateProjectCaches(id)");
// Sibling bundles carry the shared list too; dropping only the current id
// leaves every OTHER cached page missing the new entry until a reload.
expect(body).toContain("eid !== String(id)");
});

it("invalidates BEFORE refreshing, so no emit can race the stale bundle back in", () => {
// Order is the bug: refreshEnvironments' correct patch only holds until
// the cached bundle is re-emitted. Invalidation has to land first.
expect(body.indexOf("invalidateProjectCaches(id)")).toBeLessThan(
body.indexOf("await refreshEnvironments()"),
);
});
});

describe("deleting an environment leaves no dead entry behind", () => {
const handler = page.slice(
page.indexOf("const handleDeleteProject"),
page.indexOf("const renderTabContent"),
);

// Pinned on the helper's DEFINITION: proves the teardown actually drops
// caches rather than that a function by that name exists.
const helper = handler.slice(
handler.indexOf("const invalidateAfterTeardown = "),
handler.indexOf("\n try {"),
);

it("invalidates every known id — the deleted one and its stale siblings", () => {
expect(helper).toContain("invalidateProjectCaches(eid)");
expect(helper).toContain("for (const env of environments ?? [])");
});

it("runs on ALL THREE success exits", () => {
// Two inside try (full ok + unrecoverable-partial) and the 404 in catch:
// another tab already deleted the row, so this tab's caches are
// guaranteed stale there. Real failures (409 active-work,
// deletion-in-progress, teardown-failed) must NOT invalidate.
expect(handler.split("invalidateAfterTeardown();").length - 1).toBeGreaterThanOrEqual(3);
});

it("the cross-tab 404 exit invalidates before navigating home", () => {
const notFoundExit = handler.slice(
handler.indexOf("// 404: someone else already deleted"),
handler.indexOf("showToast(getApiErrorMessage(err"),
);
expect(notFoundExit).toContain("invalidateAfterTeardown();");
expect(notFoundExit.indexOf("invalidateAfterTeardown();")).toBeLessThan(
notFoundExit.indexOf('router.push("/")'),
);
});
});