diff --git a/app/src/hooks/useNotes.test.ts b/app/src/hooks/useNotes.test.ts index 7d58b75..ab9445c 100644 --- a/app/src/hooks/useNotes.test.ts +++ b/app/src/hooks/useNotes.test.ts @@ -163,4 +163,47 @@ describe("useNotes", () => { // listNotes should be called again after the save expect(listNotes).toHaveBeenCalledTimes(callCountBefore + 1); }); + + it("does not overwrite the new project's notes when a stale save resolves", async () => { + const { result, rerender } = renderHook( + ({ projectId }: { projectId: string }) => useNotes(projectId), + { initialProps: { projectId: "p1" } }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.notes[0].id).toBe("n1"); + + // Start a save for p1 that hangs + let resolveSave: ((note: Note) => void) | undefined; + saveNote.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + + let savePromise: Promise | undefined; + await act(async () => { + savePromise = result.current.saveNote(note({ id: "n1" })); + }); + + // Switch to p2 while the save is in flight + listNotes.mockResolvedValueOnce([note({ id: "n2", title: "Project 2 Note" })]); + rerender({ projectId: "p2" }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // Now p2's note should be displayed + expect(result.current.notes).toHaveLength(1); + expect(result.current.notes[0].id).toBe("n2"); + + // Resolve the stale p1 save + listNotes.mockResolvedValueOnce([note({ id: "n1", body: "edited" })]); + await act(async () => { + resolveSave?.(note({ id: "n1", body: "edited" })); + await savePromise; + }); + + // p2's note should still be displayed, not p1's + expect(result.current.notes).toHaveLength(1); + expect(result.current.notes[0].id).toBe("n2"); + }); }); diff --git a/app/src/hooks/useNotes.ts b/app/src/hooks/useNotes.ts index 74320e7..4d99b21 100644 --- a/app/src/hooks/useNotes.ts +++ b/app/src/hooks/useNotes.ts @@ -34,6 +34,8 @@ export function useNotes(projectId: string) { const [saveState, setSaveState] = useState({ status: "idle", error: null }); const pushToast = useAppState((s) => s.pushToast); const resetTimer = useRef | null>(null); + const currentProjectId = useRef(projectId); + currentProjectId.current = projectId; useEffect(() => { if (!projectId) { @@ -94,7 +96,12 @@ export function useNotes(projectId: string) { // what a reload would show. try { const reloaded = await commands.listNotes(projectId); - setNotes(reloaded); + // Guard against a stale callback: if the project changed while the save was in + // flight, don't overwrite the new project's list with the old one. The save itself + // still succeeded, so succeeded() still fires; only the list replacement is skipped. + if (currentProjectId.current === projectId) { + setNotes(reloaded); + } } catch { // Keep the save reported as successful (it was) and leave the existing list alone // rather than clearing it if the re-read fails. @@ -126,7 +133,12 @@ export function useNotes(projectId: string) { async (noteId: string) => { try { await commands.deleteNote(projectId, noteId); - setNotes((current) => current.filter((n) => n.id !== noteId)); + // Guard against a stale callback: if the project changed while the delete was in + // flight, don't modify the list. Note ids are UUIDs so a stale filter cannot match + // another project's note, but applying the same guard for consistency. + if (currentProjectId.current === projectId) { + setNotes((current) => current.filter((n) => n.id !== noteId)); + } return true; } catch (e) { pushToast({ kind: "error", message: "Could not delete note", detail: String(e) });