Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 4s
Build App (Preview) / build-macos (pull_request) Successful in 2m48s
Build App (Preview) / build-linux (pull_request) Successful in 6m2s
Build App (Preview) / build-windows (pull_request) Successful in 6m4s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
One gesture puts two requests in flight. With the tab already loaded, clicking the dock toggle while the textarea has focus fires `blur` -> `saveNote` and the dock's mount -> `list_notes` in the same tick. The save finishes and its re-read writes the post-save list; the mount's read -- issued earlier, still out -- then lands its pre-save snapshot on top, and both panels show stale text until something else refreshes. `mutationChains` could not have caught this: it orders a project's writes against each other and the mount load is a read outside it. Putting the read on the chain would work, but it buys correctness with latency the user feels -- a panel mount waiting behind `save_note`'s double-fsync write -- and leaves a "mutation chain" holding reads. The two requests are not competing for a resource; the loser's result is simply older. So every write into `notesByProject[p]` now claims a per-project sequence when the request behind it is issued, and `commitNotes` drops one whose sequence predates what is already cached. Reads take their sequence at issue time, since being ordered by resolution is the bug. Local patches -- the filter behind a confirmed delete, the prepend behind a failed re-read -- take a fresh one at commit time, because they are authoritative then rather than derived from an earlier read, and anything still in flight behind them is genuinely stale. A failed read commits under its *own* sequence, not a fresh one, so its empty list cannot beat a later read that has the real answer. `isCurrent()` stays, and is not folded in. It guards `setSaveState`, not the cache: it asks whether this *panel* is still showing the project a save was made for, which a per-project counter cannot answer -- two panels on one project share every sequence value. Ordering and panel identity are two questions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
import { useNotes } from "./useNotes";
|
|
import { useAppState } from "../store/appState";
|
|
import type { Note } from "../lib/types";
|
|
|
|
const listNotes = vi.fn();
|
|
const saveNote = vi.fn();
|
|
const deleteNote = vi.fn();
|
|
|
|
vi.mock("../lib/tauri-commands", () => ({
|
|
listNotes: (p: string) => listNotes(p),
|
|
saveNote: (p: string, n: Note) => saveNote(p, n),
|
|
deleteNote: (p: string, id: string) => deleteNote(p, id),
|
|
}));
|
|
|
|
const note = (over: Partial<Note> = {}): Note => ({
|
|
id: "n1",
|
|
title: "Deploy",
|
|
body: "one\ntwo",
|
|
pinned: false,
|
|
created_at: "2026-09-01T00:00:00Z",
|
|
updated_at: "2026-09-01T00:00:00Z",
|
|
...over,
|
|
});
|
|
|
|
/** The toasts the hook pushed. The store is real, so this is what a user sees. */
|
|
const toasts = () => useAppState.getState().toasts;
|
|
|
|
/**
|
|
* A stand-in for the Rust store: one list per project, upsert and delete
|
|
* applied to it, `list_notes` reading it back. Several of these tests are about
|
|
* what the *list* looks like after a sequence of writes, which a per-call
|
|
* `mockResolvedValueOnce` cannot express.
|
|
*/
|
|
function fakeBackend(initial: Record<string, Note[]> = {}) {
|
|
const files: Record<string, Note[]> = { ...initial };
|
|
listNotes.mockImplementation(async (p: string) => [...(files[p] ?? [])]);
|
|
saveNote.mockImplementation(async (p: string, n: Note) => {
|
|
const list = files[p] ?? (files[p] = []);
|
|
const at = list.findIndex((x) => x.id === n.id);
|
|
if (at === -1) list.unshift(n);
|
|
else list[at] = n;
|
|
return n;
|
|
});
|
|
deleteNote.mockImplementation(async (p: string, id: string) => {
|
|
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
|
});
|
|
return files;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// The cache is shared app state now, so it has to be reset like any other.
|
|
useAppState.setState({ notesByProject: {}, notesLoading: {}, toasts: [] });
|
|
listNotes.mockResolvedValue([note()]);
|
|
saveNote.mockImplementation(async (_p: string, n: Note) => n);
|
|
deleteNote.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("useNotes", () => {
|
|
it("loads a project's notes on mount", async () => {
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
expect(listNotes).toHaveBeenCalledWith("p1");
|
|
expect(result.current.notes).toHaveLength(1);
|
|
});
|
|
|
|
it("reports a failed save instead of swallowing it", async () => {
|
|
// Silent save failure is data loss: the user sees their text on screen and
|
|
// believes it is stored. Same reason `useSaveState` exists.
|
|
saveNote.mockRejectedValueOnce(new Error("disk full"));
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
let ok: boolean | undefined;
|
|
await act(async () => {
|
|
ok = await result.current.saveNote(note({ body: "edited" }));
|
|
});
|
|
|
|
expect(ok).toBe(false);
|
|
expect(result.current.saveState.status).toBe("failed");
|
|
expect(toasts()).toHaveLength(1);
|
|
});
|
|
|
|
it("replaces the saved note in place rather than appending", async () => {
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
// Mock the re-read to return the edited note
|
|
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(note({ body: "edited" }));
|
|
});
|
|
|
|
expect(result.current.notes).toHaveLength(1);
|
|
expect(result.current.notes[0].body).toBe("edited");
|
|
});
|
|
|
|
it("drops a deleted note from the list", async () => {
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
await act(async () => {
|
|
await result.current.deleteNote("n1");
|
|
});
|
|
|
|
expect(deleteNote).toHaveBeenCalledWith("p1", "n1");
|
|
expect(result.current.notes).toHaveLength(0);
|
|
});
|
|
|
|
it("does not load anything for an empty project id", async () => {
|
|
// The dock renders with no project selected; it must not fire a command
|
|
// for the empty string.
|
|
renderHook(() => useNotes(""));
|
|
await waitFor(() => expect(listNotes).not.toHaveBeenCalled());
|
|
});
|
|
|
|
it("clears the first project's notes when the projectId changes to another non-empty value", 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).toHaveLength(1);
|
|
|
|
// Change to a different project before the new fetch resolves
|
|
listNotes.mockImplementationOnce(() => new Promise(() => {})); // never resolves
|
|
rerender({ projectId: "p2" });
|
|
|
|
// The old notes should be cleared immediately
|
|
expect(result.current.notes).toHaveLength(0);
|
|
});
|
|
|
|
it("leaves no stale notes on screen when a load fails", async () => {
|
|
listNotes.mockResolvedValueOnce([note()]);
|
|
const { result, rerender } = renderHook(
|
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
|
{ initialProps: { projectId: "p1" } },
|
|
);
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
expect(result.current.notes).toHaveLength(1);
|
|
|
|
// Switch to a project whose load fails
|
|
listNotes.mockRejectedValueOnce(new Error("load failed"));
|
|
rerender({ projectId: "p2" });
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
expect(result.current.notes).toHaveLength(0);
|
|
expect(toasts()).toHaveLength(1);
|
|
});
|
|
|
|
it("ends with the list the backend returned when saving a new note", async () => {
|
|
// Initially one note
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
expect(result.current.notes).toHaveLength(1);
|
|
|
|
// Saving a new note (not in the current list) re-reads and ends with the backend's list
|
|
const newNote = note({ id: "n2", title: "New" });
|
|
listNotes.mockResolvedValueOnce([newNote, note()]);
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(newNote);
|
|
});
|
|
|
|
expect(result.current.notes).toHaveLength(2);
|
|
expect(result.current.notes[0].id).toBe("n2");
|
|
});
|
|
|
|
it("re-reads the list after a successful save rather than patching in place", async () => {
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
const callCountBefore = listNotes.mock.calls.length;
|
|
listNotes.mockResolvedValueOnce([note({ body: "edited" })]);
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(note({ body: "edited" }));
|
|
});
|
|
|
|
// 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<boolean> | 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");
|
|
});
|
|
|
|
it("keeps the notes already on screen when a refresh fails", async () => {
|
|
// The second surface mounting for a project is a refresh behind a list the
|
|
// user is already reading. One shared cache means a failed refresh would
|
|
// otherwise blank both panels.
|
|
fakeBackend({ p1: [note()] });
|
|
const tab = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
|
|
|
listNotes.mockRejectedValueOnce(new Error("read failed"));
|
|
const dock = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(toasts()).toHaveLength(1));
|
|
|
|
expect(tab.result.current.notes).toHaveLength(1);
|
|
expect(dock.result.current.notes).toHaveLength(1);
|
|
});
|
|
|
|
it("does not report the old project's save on the new project's indicator", async () => {
|
|
// The indicator is per-panel and reads "Saved ✓". Firing it after a switch
|
|
// tells the user their *current* project was written when it was not.
|
|
const { result, rerender } = renderHook(
|
|
({ projectId }: { projectId: string }) => useNotes(projectId),
|
|
{ initialProps: { projectId: "p1" } },
|
|
);
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
let resolveSave: ((n: Note) => void) | undefined;
|
|
saveNote.mockImplementationOnce(
|
|
() => new Promise((resolve) => (resolveSave = resolve)),
|
|
);
|
|
let savePromise: Promise<boolean> | undefined;
|
|
await act(async () => {
|
|
savePromise = result.current.saveNote(note({ body: "edited" }));
|
|
});
|
|
|
|
rerender({ projectId: "p2" });
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
await act(async () => {
|
|
resolveSave?.(note({ body: "edited" }));
|
|
await savePromise;
|
|
});
|
|
|
|
expect(result.current.saveState.status).toBe("idle");
|
|
});
|
|
|
|
it("still reports a save on the indicator of the project it was made for", async () => {
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(note({ body: "edited" }));
|
|
});
|
|
|
|
expect(result.current.saveState.status).toBe("saved");
|
|
});
|
|
|
|
it("serialises a project's writes so an edit cannot be re-inserted after its delete", async () => {
|
|
// Clicking Delete while the textarea has focus fires blur first, so a save
|
|
// and a delete go out back to back. The Rust write lock stops them
|
|
// interleaving but does not order them: a delete that wins the lock is
|
|
// undone by the upsert behind it, and the note comes back on next load.
|
|
const files = fakeBackend({ p1: [note()] });
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
const order: string[] = [];
|
|
saveNote.mockImplementationOnce(async (p: string, n: Note) => {
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
order.push("save");
|
|
files[p] = [n];
|
|
return n;
|
|
});
|
|
deleteNote.mockImplementationOnce(async (p: string, id: string) => {
|
|
order.push("delete");
|
|
files[p] = (files[p] ?? []).filter((x) => x.id !== id);
|
|
});
|
|
|
|
await act(async () => {
|
|
const save = result.current.saveNote(note({ body: "typo fixed" }));
|
|
const del = result.current.deleteNote("n1");
|
|
await Promise.all([save, del]);
|
|
});
|
|
|
|
expect(order).toEqual(["save", "delete"]);
|
|
expect(files.p1).toHaveLength(0);
|
|
expect(result.current.notes).toHaveLength(0);
|
|
});
|
|
|
|
it("keeps a new note when another note is saved right after it", async () => {
|
|
// A purely local draft used to be wiped by the next re-read: two clicks of
|
|
// "New note", type in the second, blur, and the first row was gone.
|
|
const files = fakeBackend({ p1: [note()] });
|
|
const { result } = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
|
|
let first: Note | null = null;
|
|
await act(async () => {
|
|
first = await result.current.createNote();
|
|
await result.current.createNote();
|
|
});
|
|
expect(result.current.notes).toHaveLength(3);
|
|
|
|
await act(async () => {
|
|
await result.current.saveNote(note({ body: "edited" }));
|
|
});
|
|
|
|
expect(result.current.notes).toHaveLength(3);
|
|
expect(result.current.notes.some((n) => n.id === first!.id)).toBe(true);
|
|
expect(files.p1).toHaveLength(3);
|
|
});
|
|
|
|
it("shares one cache between every hook watching the same project", async () => {
|
|
// The Project Home sub-tab and the dock both mount a panel for the same
|
|
// project. Two caches meant an edit in one was invisible to the other, and
|
|
// the other's next blur wrote its stale copy back over it.
|
|
fakeBackend({ p1: [note()] });
|
|
const tab = renderHook(() => useNotes("p1"));
|
|
const dock = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
|
await waitFor(() => expect(dock.result.current.loading).toBe(false));
|
|
|
|
// One read for both — the in-flight flag is per project, not per hook.
|
|
expect(listNotes).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
await dock.result.current.saveNote(note({ body: "written in the dock" }));
|
|
});
|
|
|
|
expect(tab.result.current.notes[0].body).toBe("written in the dock");
|
|
expect(tab.result.current.notes).toBe(dock.result.current.notes);
|
|
});
|
|
|
|
it("does not blank an already-loaded list when a second panel mounts", async () => {
|
|
fakeBackend({ p1: [note()] });
|
|
const tab = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
|
|
|
const dock = renderHook(() => useNotes("p1"));
|
|
// No "Loading notes…" flash on the second surface.
|
|
expect(dock.result.current.loading).toBe(false);
|
|
expect(dock.result.current.notes).toHaveLength(1);
|
|
});
|
|
|
|
it("does not let a slow mount read overwrite a fresher post-save refresh", async () => {
|
|
// One gesture, two requests. The tab is already loaded; the user clicks the
|
|
// dock toggle with the textarea focused, so `blur` → `saveNote` and the
|
|
// dock's mount → `list_notes` are issued in the same tick. The save's
|
|
// re-read writes the post-save list; the mount's read — issued earlier,
|
|
// still in flight — must not then land its pre-save snapshot on top of it.
|
|
const files = fakeBackend({ p1: [note({ body: "before" })] });
|
|
const tab = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
|
expect(tab.result.current.notes[0].body).toBe("before");
|
|
|
|
// The dock's mount read: it snapshots the list as it is *now* (pre-save)
|
|
// and hangs, standing in for a plain read that is slower than
|
|
// `save_note`'s double-fsync write plus the re-read behind it.
|
|
let releaseMountRead: (() => void) | undefined;
|
|
listNotes.mockImplementationOnce(async (p: string) => {
|
|
const preSave = [...(files[p] ?? [])];
|
|
await new Promise<void>((resolve) => {
|
|
releaseMountRead = resolve;
|
|
});
|
|
return preSave;
|
|
});
|
|
const dock = renderHook(() => useNotes("p1"));
|
|
expect(releaseMountRead).toBeDefined();
|
|
|
|
// The save and its re-read complete while that read is still out.
|
|
await act(async () => {
|
|
await tab.result.current.saveNote(note({ body: "after" }));
|
|
});
|
|
expect(tab.result.current.notes[0].body).toBe("after");
|
|
|
|
// Now the stale read lands.
|
|
await act(async () => {
|
|
releaseMountRead!();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(tab.result.current.notes[0].body).toBe("after");
|
|
expect(dock.result.current.notes[0].body).toBe("after");
|
|
});
|
|
|
|
it("does not let a slow mount read resurrect a note deleted while it was in flight", async () => {
|
|
// The other half of the same ordering rule: a confirmed delete is newer
|
|
// than any read issued before it finished, so the read's pre-delete list
|
|
// must not be written back over the shortened one.
|
|
const files = fakeBackend({ p1: [note()] });
|
|
const tab = renderHook(() => useNotes("p1"));
|
|
await waitFor(() => expect(tab.result.current.loading).toBe(false));
|
|
|
|
let releaseMountRead: (() => void) | undefined;
|
|
listNotes.mockImplementationOnce(async (p: string) => {
|
|
const preDelete = [...(files[p] ?? [])];
|
|
await new Promise<void>((resolve) => {
|
|
releaseMountRead = resolve;
|
|
});
|
|
return preDelete;
|
|
});
|
|
const dock = renderHook(() => useNotes("p1"));
|
|
expect(releaseMountRead).toBeDefined();
|
|
|
|
await act(async () => {
|
|
await tab.result.current.deleteNote("n1");
|
|
});
|
|
expect(tab.result.current.notes).toHaveLength(0);
|
|
|
|
await act(async () => {
|
|
releaseMountRead!();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(tab.result.current.notes).toHaveLength(0);
|
|
expect(dock.result.current.notes).toHaveLength(0);
|
|
});
|
|
});
|