fix: close note-switching race conditions and restore offline-first note open

Three issues found in review, all around switching to a different note
while the editor is already open (via a note-link or backlink chip):

- TiptapEditor's onTransaction/onUpdate had no guard against writing
  state after the component is torn down. Removing a focused editor's
  DOM node fires a native blur, which ProseMirror turns into one last
  transaction synchronously during that same teardown pass, crashing
  with state_unsafe_mutation. Deferred the onTransaction write to a
  microtask (checked against a destroyed flag) so it lands after
  Svelte's current pass finishes; guarded onUpdate synchronously.

- openEditor/openNoteById in NotesView.svelte awaited a network fetch
  before ever showing the editor, for every note open (not just
  link-driven navigation) — a regression for an offline-first app, and
  the source of a real race: two rapid note-link clicks left two
  fetches in flight with no ordering guarantee, so whichever resolved
  last won, not whichever was clicked last. Reverted to opening
  instantly from local/IndexedDB data (with a request-id guard against
  the residual race), and instead fetch backlinks lazily in
  NoteEditor.svelte only if not already present — mirroring the
  existing attachments-fetch pattern already in that file. Also
  applied the same post-teardown guard to that pre-existing attachments
  effect, since remounting on note-switch newly exposes it to the same
  class of race.

- FormattingToolbar's closeDropdowns() cleared search results but never
  cancelled the pending debounce timer, so a stale query could
  repopulate results after the dropdown had already closed. Added the
  missing clearTimeout, plus a try/catch around the search fetch for
  consistency with the pattern used elsewhere in the app.

Verified: pnpm check (0 errors), full unit suite (316/316), and the
existing e2e suite including all note-linking scenarios (19/19) all
pass against these changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-08-05 10:51:08 +02:00
committed by Fred Cabassut
co-authored by Claude Sonnet 5
parent 534d84224d
commit 3eb8d2eeab
4 changed files with 96 additions and 43 deletions
+22 -31
View File
@@ -32,18 +32,8 @@
// Use SvelteKit's replaceState (not the raw history API) so the router keeps
// ownership of the entry — NoteEditor's close-on-back relies on popstate being
// handled by SvelteKit.
async function openEditor(note: Note) {
try {
const res = await fetch(`/api/notes/${note.id}`);
if (res.ok) {
const full = await res.json();
editingNote = { ...note, backlinks: full.backlinks ?? [] };
} else {
editingNote = note;
}
} catch {
editingNote = note;
}
function openEditor(note: Note) {
editingNote = note;
replaceState(`#${note.id}`, {});
}
@@ -54,25 +44,18 @@
replaceState(location.pathname, {});
}
async function openNoteById(noteId: string) {
showNewNote = false;
newNoteChecklist = false;
editingNote = null;
// Reads from the local IndexedDB cache rather than fetching over the network —
// this keeps note-link navigation instant and offline-safe (matching how the
// rest of the app opens notes), and backlinks are filled in progressively by
// NoteEditor's own fetch-if-not-pre-populated effect once the editor is open,
// the same pattern already used there for attachments.
let openNoteRequestId = 0;
try {
const res = await fetch(`/api/notes/${noteId}`);
if (res.ok) {
const full = await res.json();
editingNote = full;
replaceState(`#${noteId}`, {});
}
} catch {
const note = await getIdbNote(noteId);
if (note) {
editingNote = note;
replaceState(`#${noteId}`, {});
}
}
async function openNoteById(noteId: string) {
const requestId = ++openNoteRequestId;
const note = await getIdbNote(noteId);
if (requestId !== openNoteRequestId) return; // a newer navigation superseded this one
if (note) openEditor(note);
}
function handleReorder(noteIds: string[]) {
@@ -173,5 +156,13 @@
{/if}
{#if editingNote}
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
<!-- Forces a full destroy+remount on note-switch (e.g. clicking a note-link
while the editor is already open) instead of reusing the same instance
with a new `note` prop — a persisting instance's own back-navigation
effect misreads `openEditor`'s replaceState call as "browser back was
pressed" and closes the editor, and its once-only local state never
resyncs to the new note otherwise. -->
{#key editingNote.id}
<NoteEditor note={editingNote} onClose={closeEditor} onOpenNote={openNoteById} />
{/key}
{/if}