Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/artifact-delete-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Prevent deleted artifacts from briefly reappearing after returning to the artifact gallery.
51 changes: 41 additions & 10 deletions e2e/scenarios/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
releaseListRefresh = resolve;
});
const listRefreshStarted = new Promise<void>((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);
});
});

Expand Down
15 changes: 13 additions & 2 deletions packages/react/src/pages/artifact-detail.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
Loading