diff --git a/apps/dashboard/src/app/(dashboard)/projects/[id]/[[...slug]]/page.tsx b/apps/dashboard/src/app/(dashboard)/projects/[id]/[[...slug]]/page.tsx index 0d0bf6630..cfafa0c93 100644 --- a/apps/dashboard/src/app/(dashboard)/projects/[id]/[[...slug]]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/projects/[id]/[[...slug]]/page.tsx @@ -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"; @@ -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([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, @@ -651,6 +671,7 @@ const ProjectSettingsContent = () => { ); } invalidateSidebarNavCounts(); + invalidateAfterTeardown(); router.push("/"); return; } @@ -667,6 +688,7 @@ const ProjectSettingsContent = () => { t.projects.delete.partialCleanupTitle, ); invalidateSidebarNavCounts(); + invalidateAfterTeardown(); router.push("/"); return; } @@ -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; } diff --git a/apps/dashboard/src/context/ProjectSettingsContext.tsx b/apps/dashboard/src/context/ProjectSettingsContext.tsx index 293b3fea8..11b9d215c 100644 --- a/apps/dashboard/src/context/ProjectSettingsContext.tsx +++ b/apps/dashboard/src/context/ProjectSettingsContext.tsx @@ -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"; @@ -809,10 +813,21 @@ export const ProjectSettingsProvider: React.FC = ({ 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 diff --git a/apps/dashboard/src/context/project-settings-environments.test.ts b/apps/dashboard/src/context/project-settings-environments.test.ts new file mode 100644 index 000000000..f008030b2 --- /dev/null +++ b/apps/dashboard/src/context/project-settings-environments.test.ts @@ -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("/")'), + ); + }); +});