From c347e99332596e70a9c9deaedc6c898c88d741f8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 21 Aug 2026 07:41:03 -0500 Subject: [PATCH] Prevent duplicate interaction submissions in runner lab --- .../issue-thread/issue-thread.spec.ts | 3 + .../devtools/issue-thread/src/App.tsx | 56 +++++++++++++------ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts index bc94f1dc9ce..9013be606ca 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -1010,7 +1010,9 @@ test.describe("pending-request live region", () => { await expect(live).toHaveText(PENDING_ANNOUNCEMENT); await expect(page.locator('[data-composer-state="waiting"]')).toBeVisible(); + let interactionRequests = 0; await page.route("**/api/capability/ui/interaction", async (route) => { + interactionRequests += 1; await route.fulfill({ status: 200, contentType: "application/json", @@ -1026,6 +1028,7 @@ test.describe("pending-request live region", () => { await card.getByRole("button", { name: "Submit answers" }).click(); await expect(card).toHaveAttribute("data-interaction-state", "answered"); + expect(interactionRequests).toBe(1); await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1); await expect(live).not.toContainText(PENDING_ANNOUNCEMENT); await expect(live).toHaveText("Your answer was recorded."); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx index fe149246439..28a8c962651 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx @@ -526,30 +526,54 @@ export function App() { const respond = useCallback( (response: CapabilityInteractionResponse) => { - setSnapshot((current) => { - if (current === null) return current; - if (route.mode === "live") { - void capabilityLiveClient - .respond(current.sessionId, response.interactionId, response.outcome, response.result) - .then((next) => setSnapshot(next.view)) - .catch((cause) => setActionError(describe(cause))); - return { + if (snapshot === null) return; + if (route.mode !== "live") { + setSnapshot(applyFakeInteractionResponse(snapshot, response)); + setAnnouncement(ANSWERED_ANNOUNCEMENT); + return; + } + + // Network effects must never live inside a React state updater. Strict + // Mode intentionally invokes updater functions more than once in + // development, which previously submitted every interaction twice: the + // first request resolved it and the duplicate produced "not pending". + const sessionId = snapshot.sessionId; + setActionError(null); + setSnapshot((current) => current === null ? current : { + ...current, + turns: current.turns.map((turn) => ({ + ...turn, + items: turn.items.map((item) => + item.kind === "interaction" && item.interactionId === response.interactionId + ? { ...item, state: "submitting" as const, stateLabel: "Submitting…" } + : item, + ), + })), + }); + void capabilityLiveClient + .respond(sessionId, response.interactionId, response.outcome, response.result) + .then((next) => { + setSnapshot(next.view); + setAnnouncement(ANSWERED_ANNOUNCEMENT); + }) + .catch((cause) => { + setActionError(describe(cause)); + setSnapshot((current) => current === null ? current : { ...current, turns: current.turns.map((turn) => ({ ...turn, items: turn.items.map((item) => - item.kind === "interaction" && item.interactionId === response.interactionId - ? { ...item, state: "submitting" as const, stateLabel: "Submitting…" } + item.kind === "interaction" && + item.interactionId === response.interactionId && + item.state === "submitting" + ? { ...item, state: "pending" as const, stateLabel: "Waiting for you" } : item, ), })), - }; - } - return applyFakeInteractionResponse(current, response); - }); - setAnnouncement(ANSWERED_ANNOUNCEMENT); + }); + }); }, - [route.mode], + [route.mode, snapshot], ); /**