From cb5031a630de3a34bc156217e5128f29155c6827 Mon Sep 17 00:00:00 2001 From: baggiiiie Date: Mon, 31 Aug 2026 11:10:25 +0800 Subject: [PATCH] Keep artifact deletion optimistic across navigation --- .changeset/artifact-delete-navigation.md | 5 ++ e2e/scenarios/artifacts.test.ts | 51 ++++++++++++++++---- packages/react/src/pages/artifact-detail.tsx | 15 +++++- 3 files changed, 59 insertions(+), 12 deletions(-) create mode 100644 .changeset/artifact-delete-navigation.md diff --git a/.changeset/artifact-delete-navigation.md b/.changeset/artifact-delete-navigation.md new file mode 100644 index 0000000000..aa9ba333ad --- /dev/null +++ b/.changeset/artifact-delete-navigation.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Prevent deleted artifacts from briefly reappearing after returning to the artifact gallery. diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts index fa9a82d03b..6512301410 100644 --- a/e2e/scenarios/artifacts.test.ts +++ b/e2e/scenarios/artifacts.test.ts @@ -634,25 +634,56 @@ scenario( ); yield* browser.session(identity, async ({ page, step }) => { - await step("Delete the artifact from the list", async () => { + let releaseListRefresh = () => {}; + let markListRefreshStarted = () => {}; + const listRefreshGate = new Promise((resolve) => { + releaseListRefresh = resolve; + }); + const listRefreshStarted = new Promise((resolve) => { + markListRefreshStarted = resolve; + }); + + await step("Open the artifact and delete it from its detail page", async () => { await visit(page, "/artifacts"); - const card = page.locator('[data-slot="artifact-card"]').filter({ - hasText: renamedTitle, + await page.getByRole("link", { name: `Open artifact ${renamedTitle}` }).click(); + await page.getByRole("heading", { name: renamedTitle }).waitFor({ timeout: 20_000 }); + + // Hold the post-delete list refresh open. The redirected gallery must + // carry the optimistic removal across the route handoff rather than + // relying on a fast canonical response to hide a stale-cache flash. + await page.route("**/artifacts", async (route) => { + if (route.request().method() !== "GET") { + await route.continue(); + return; + } + markListRefreshStarted(); + await listRefreshGate; + await route.continue(); }); - await card.waitFor({ timeout: 20_000 }); - await card.hover(); - await card.getByRole("button", { name: "Delete" }).click(); + await page.getByRole("button", { name: "Delete" }).click(); const confirm = page.getByRole("alertdialog"); await confirm.getByRole("heading", { name: `Delete ${renamedTitle}?` }).waitFor(); await confirm.getByRole("button", { name: "Delete Artifact" }).click(); - await confirm.waitFor({ state: "hidden", timeout: 20_000 }); }); - await step("The artifact is gone from the list", async () => { - await page + await step("The redirected gallery already omits the deleted artifact", async () => { + await page.waitForURL((url) => /\/artifacts\/?$/.test(url.pathname), { + timeout: 20_000, + }); + await page.getByRole("heading", { name: "Saved artifacts" }).waitFor(); + await listRefreshStarted; + + const deletedCardCount = await page .getByRole("link", { name: `Open artifact ${renamedTitle}` }) - .waitFor({ state: "detached", timeout: 20_000 }); + .count(); + releaseListRefresh(); + await page.unrouteAll({ behavior: "wait" }); + + expect( + deletedCardCount, + "the optimistic delete survives navigation while the list refresh is pending", + ).toBe(0); }); }); diff --git a/packages/react/src/pages/artifact-detail.tsx b/packages/react/src/pages/artifact-detail.tsx index c782474e04..7ce115a8a1 100644 --- a/packages/react/src/pages/artifact-detail.tsx +++ b/packages/react/src/pages/artifact-detail.tsx @@ -1,5 +1,5 @@ import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; -import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useAtomMount, useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { ClientOnly, useNavigate } from "@tanstack/react-router"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; @@ -8,7 +8,12 @@ import type { ArtifactId } from "@executor-js/sdk/shared"; import { trackEvent } from "../api/analytics"; import { useArtifactRenderer, usePreloadArtifactRenderer } from "../api/artifact-renderer"; -import { artifactAtom, removeArtifactOptimistic, renameArtifactOptimistic } from "../api/atoms"; +import { + artifactAtom, + artifactsOptimisticAtom, + removeArtifactOptimistic, + renameArtifactOptimistic, +} from "../api/atoms"; import { artifactWriteKeys } from "../api/reactivity-keys"; import { createHttpShellHost } from "../api/shell-host"; import { @@ -38,6 +43,12 @@ import { RenameArtifactDialog } from "./artifact-rename-dialog"; export function ArtifactDetailPage(props: { readonly artifactId: ArtifactId }) { const artifact = useAtomValue(artifactAtom(props.artifactId)); const refresh = useAtomRefresh(artifactAtom(props.artifactId)); + + // Detail-page writes reduce over the gallery's optimistic surface. Keep that + // surface mounted here so its rename/delete transition survives the route + // handoff back to the gallery instead of exposing the list query's cached + // pre-mutation value while its authoritative refresh is still in flight. + useAtomMount(artifactsOptimisticAtom); const doRename = useAtomSet(renameArtifactOptimistic, { mode: "promiseExit" }); const doRemove = useAtomSet(removeArtifactOptimistic, { mode: "promiseExit" }); const navigate = useNavigate();