From 6e4cde5e01be5c793ef32d836283fed7e15db4a7 Mon Sep 17 00:00:00 2001 From: Umupa Date: Tue, 6 Oct 2026 13:41:00 +0800 Subject: [PATCH] fix: preserve focused note title drafts Autosave normalized empty and spaced titles while the input was still focused, then cleared the local edit. Keep that draft until blur or editor teardown, while continuing to save body edits and non-empty titles. Cover delayed saves and unmounts with regression tests for #768. --- src/features/notes/ui/NotesView.test.ts | 118 ++++++++++++++++++++++++ src/features/notes/ui/NotesView.tsx | 16 +++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/features/notes/ui/NotesView.test.ts b/src/features/notes/ui/NotesView.test.ts index ef17681e7..636074c00 100644 --- a/src/features/notes/ui/NotesView.test.ts +++ b/src/features/notes/ui/NotesView.test.ts @@ -86,6 +86,124 @@ async function render(projects = recents, cwd = "/work/Edefyn") { ); } +function typeInto(field: HTMLInputElement | HTMLTextAreaElement, value: string) { + const prototype = field instanceof HTMLInputElement + ? HTMLInputElement.prototype + : HTMLTextAreaElement.prototype; + Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(field, value); + field.dispatchEvent(new Event("input", { bubbles: true })); +} + +// https://github.com/hardbeat920/monocode/issues/768 +it.each([ + { title: "Untitled", body: "This is the note body.", fallback: "This is the note body." }, + { title: "Custom title", body: "Intro\n# Generated title\nBody", fallback: "Generated title" }, +])("keeps a cleared $title draft until the title field blurs", async (note) => { + vi.useFakeTimers(); + stored = { ...stored, id: `note-title-${note.title}`, title: note.title, body: note.body }; + await render(); + const title = container.querySelector('[aria-label="Note title"]')!; + act(() => { + title.focus(); + typeInto(title, ""); + }); + await act(async () => vi.advanceTimersByTime(800)); + + expect(document.activeElement).toBe(title); + expect(title.value).toBe(""); + expect(stored.title).toBe(note.title); + + await act(async () => title.blur()); + expect(title.value).toBe(note.fallback); + expect(stored.title).toBe(note.fallback); +}); + +it("autosaves a replacement title without trimming the focused draft", async () => { + vi.useFakeTimers(); + stored = { ...stored, id: "note-title-replacement" }; + await render(); + const title = container.querySelector('[aria-label="Note title"]')!; + act(() => { + title.focus(); + typeInto(title, " Replacement title "); + }); + await act(async () => vi.advanceTimersByTime(400)); + + expect(stored.title).toBe("Replacement title"); + expect(title.value).toBe(" Replacement title "); + await act(async () => title.blur()); + expect(title.value).toBe("Replacement title"); +}); + +it.each(["refocus", "unmount"] as const)( + "retains a cleared title when a body save completes after %s", + async (action) => { + vi.useFakeTimers(); + stored = { ...stored, id: `note-title-body-save-${action}` }; + await render(); + const source = [...container.querySelectorAll('[role="tab"]')] + .find((button) => button.textContent === "Source")!; + await act(async () => source.click()); + const body = container.querySelector("textarea.markdown-source-field")!; + const title = container.querySelector('[aria-label="Note title"]')!; + const save = invoke.getMockImplementation()!; + let finishSave!: () => void; + const saving = new Promise((resolve) => { finishSave = resolve; }); + let holdSave = true; + invoke.mockImplementation(async (command, args) => { + if (command === "notes_upsert" && holdSave) { + holdSave = false; + await saving; + } + return save(command, args); + }); + act(() => { + typeInto(body, "Updated body."); + title.focus(); + typeInto(title, ""); + }); + await act(async () => vi.advanceTimersByTime(400)); + expect(invoke).toHaveBeenCalledWith("notes_upsert", { + note: expect.objectContaining({ body: "Updated body." }), + }); + if (action === "refocus") { + // Blur queues a commit, then a new edit begins before the save finishes. + await act(async () => title.blur()); + act(() => { + title.focus(); + typeInto(title, ""); + }); + } else { + await act(async () => root.unmount()); + root = createRoot(container); + } + await act(async () => finishSave()); + + expect(stored.body).toBe("Updated body."); + if (action === "refocus") { + expect(document.activeElement).toBe(title); + expect(title.value).toBe(""); + await act(async () => title.blur()); + } + expect(stored.title).toBe("Updated body."); + }, +); + +it("commits a cleared title when its focused editor unmounts", async () => { + vi.useFakeTimers(); + stored = { ...stored, id: "note-title-unmount" }; + await render(); + const title = container.querySelector('[aria-label="Note title"]')!; + act(() => { + title.focus(); + typeInto(title, ""); + }); + await act(async () => root.unmount()); + root = createRoot(container); + expect(stored.title).toBe("Keep this text."); + expect(stored.body).toBe("Keep this text."); +}); + it("shows a preloaded note immediately while refreshing in the background", async () => { await loadNotes(); let finish!: (notes: Note[]) => void; diff --git a/src/features/notes/ui/NotesView.tsx b/src/features/notes/ui/NotesView.tsx index 04abe4d15..d5b8b9d59 100644 --- a/src/features/notes/ui/NotesView.tsx +++ b/src/features/notes/ui/NotesView.tsx @@ -579,6 +579,7 @@ function NoteEditor({ const projectChangeRef = useRef(projectChange); const noteRef = useRef(note); const dropZoneRef = useRef(null); + const titleFieldRef = useRef(null); const sourceFieldRef = useRef(null); const lastDropAt = useRef(0); const skipSave = useRef(false); @@ -607,15 +608,25 @@ function NoteEditor({ const current = latest ?? noteRef.current; const changes = editsRef.current; const nextBody = changes.body ?? current.body; + const titleFocused = document.activeElement === titleFieldRef.current; const nextTitle = - (changes.title ?? current.title).trim() || noteTitle(nextBody); + (changes.title ?? current.title).trim() || + (titleFocused ? current.title : noteTitle(nextBody)); const nextTags = changes.tags ?? current.tags; const nextProject = projectChangeRef.current; const acceptSaved = (saved: Note) => { noteRef.current = saved; // A completed save only clears the edits included in that request. const remaining = { ...editsRef.current }; - if (remaining.title === changes.title) delete remaining.title; + // Keep the focused draft, including blanks and spaces, until blur. + // Leave the draft for a queued blur or unmount save to commit as well. + if ( + remaining.title === changes.title && + !titleFocused && + document.activeElement !== titleFieldRef.current + ) { + delete remaining.title; + } if (remaining.body === changes.body) delete remaining.body; if (remaining.tags === changes.tags) delete remaining.tags; editsRef.current = remaining; @@ -820,6 +831,7 @@ function NoteEditor({ /> { editNote({ title: event.target.value });