mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
fix(ui): preserve composer drafts through StrictMode
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user