Fix critical data corruption and stale-data bugs in useNotes hook

- Finding 1 (saveNote): After a successful save, re-read the canonical list from the backend instead of patching in place. A successful save stamps a new updated_at, and the backend sorts by updated_at descending, so the record's position has changed and positional patching would disagree with what a reload would show. If the re-read fails, keep the save reported as successful and leave the existing list alone.

- Finding 2 (stale notes): Clear notes on projectId change (not only when empty) and on load failure. Previously, switching from project A to project B would leave A's notes on screen until B's fetch resolved, and if a user edited one, A's note would be written into B's notes file—cross-project data corruption. If a load fails, A's notes stay visible under B indefinitely.

- Added four new tests covering these scenarios: projectId change clears old notes, failed load leaves no stale notes, saving a new note ends with the backend's list, and saves re-read the list rather than patching.
This commit is contained in:
2026-09-01 12:45:35 -07:00
parent cd3160b1cd
commit b6ba6deb09
2 changed files with 88 additions and 9 deletions
+18 -9
View File
@@ -43,6 +43,7 @@ export function useNotes(projectId: string) {
}
let cancelled = false;
setLoading(true);
setNotes([]);
commands
.listNotes(projectId)
.then((loaded) => {
@@ -50,6 +51,7 @@ export function useNotes(projectId: string) {
})
.catch((e) => {
if (cancelled) return;
setNotes([]);
pushToast({
kind: "error",
message: "Could not load notes for this project",
@@ -85,14 +87,18 @@ export function useNotes(projectId: string) {
if (resetTimer.current) clearTimeout(resetTimer.current);
setSaveState({ status: "saving", error: null });
try {
const saved = await commands.saveNote(projectId, note);
setNotes((current) => {
const index = current.findIndex((n) => n.id === saved.id);
if (index === -1) return [saved, ...current];
const next = [...current];
next[index] = saved;
return next;
});
await commands.saveNote(projectId, note);
// Re-read the canonical list from the backend. A successful save stamps a new
// `updated_at`, and the backend sorts unpinned notes by `updated_at` descending,
// so the record's position has changed and positional patching would disagree with
// what a reload would show.
try {
const reloaded = await commands.listNotes(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.
}
succeeded();
return true;
} catch (e) {
@@ -108,7 +114,10 @@ export function useNotes(projectId: string) {
const createNote = useCallback(async () => {
const note = draft();
// Held locally first so the editor can focus it immediately; the save
// happens on blur like every other edit.
// happens on blur like every other edit. The note does not exist backend-side,
// so no re-read can place it and no backend ordering applies to it yet. Prepending
// puts it at the top where the user can see it immediately, and on the first save
// its canonical position is established.
setNotes((current) => [note, ...current]);
return note;
}, []);