fix(ui): preserve composer drafts through StrictMode

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
scotttongandPaperclip committed 2026-08-07 17:14:32 -07:00
1 parent 9dc630196d
commit 960b6e8a5d
2 files changed
+42 -4

No files matched your search

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import type { ReactElement } from "react";
import { StrictMode, type ReactElement } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -547,6 +547,18 @@ describe("TaskChatComposer", () => {
expect(onAdd).toHaveBeenCalledWith("unsent draft", undefined, undefined);
});
it("preserves a restored draft through the StrictMode effect probe", () => {
localStorage.setItem(KEY, "still here");
render(
<StrictMode>
<TaskChatComposer onAdd={vi.fn()} workMode="standard" draftKey={KEY} />
</StrictMode>,
);
expect(editable().textContent).toBe("still here");
expect(localStorage.getItem(KEY)).toBe("still here");
});
it("saves the body to localStorage after the debounce window", () => {
vi.useFakeTimers();
try {
@@ -578,6 +590,20 @@ describe("TaskChatComposer", () => {
}
});
it("flushes a pending draft before the page unloads", () => {
vi.useFakeTimers();
try {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" draftKey={KEY} />);
typeText("save before reload");
window.dispatchEvent(new Event("beforeunload"));
expect(localStorage.getItem(KEY)).toBe("save before reload");
} finally {
vi.useRealTimers();
}
});
it("clears the draft key on a successful send (not via the empty-body debounce)", async () => {
localStorage.setItem(KEY, "queued message");
const onAdd = vi.fn().mockResolvedValue(undefined);
@@ -156,7 +156,10 @@ export function TaskChatComposer({
mobile = false,
draftKey,
}: TaskChatComposerProps) {
const [body, setBody] = useState("");
// Read the initial value during state initialization. The cleanup below runs
// during React StrictMode's effect probe, so starting with an empty body and
// restoring in an effect could erase a saved draft before that restore runs.
const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const [submitting, setSubmitting] = useState(false);
const [pendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
@@ -192,6 +195,15 @@ export function TaskChatComposer({
};
}, [draftKey]);
// React unmount cleanup is not guaranteed to run during a page unload. Save
// synchronously so a recent keystroke does not depend on the debounce timer.
useEffect(() => {
if (!draftKey) return;
const flushDraft = () => saveDraft(draftKey, bodyRef.current);
window.addEventListener("beforeunload", flushDraft);
return () => window.removeEventListener("beforeunload", flushDraft);
}, [draftKey]);
const modeMeta = workModeMetaFor(pendingMode);
const canAcceptFiles = Boolean(onAttachImage || onImageUpload);
const showAssignee = Boolean(enableReassign && reassignOptions && reassignOptions.length > 0);
@@ -320,17 +332,17 @@ export function TaskChatComposer({
const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined;
setSubmitting(true);
setBody("");
try {
if (pendingMode !== workMode && onWorkModeChange) {
await onWorkModeChange(pendingMode);
}
await onAdd(fullBody, reopen, reassignment);
if (draftKey) clearDraft(draftKey);
setBody("");
setAttachments([]);
setPendingAssignee(null);
} catch {
setBody(trimmed); // restore on failure (chips stay + the draft save effect re-fires)
// Keep the body and its pending draft while the user resolves the failure.
} finally {
setSubmitting(false);
}