From 3eb8d2eeabf4e2e8840b2d83ec618df0f502949e Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Wed, 5 Aug 2026 10:18:34 +0200 Subject: [PATCH] fix: close note-switching race conditions and restore offline-first note open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/components/FormattingToolbar.svelte | 15 ++++-- src/lib/components/NoteEditor.svelte | 54 +++++++++++++++++++-- src/lib/components/NotesView.svelte | 53 +++++++++----------- src/lib/components/TiptapEditor.svelte | 17 ++++++- 4 files changed, 96 insertions(+), 43 deletions(-) diff --git a/src/lib/components/FormattingToolbar.svelte b/src/lib/components/FormattingToolbar.svelte index 24b1474..60a2bcf 100644 --- a/src/lib/components/FormattingToolbar.svelte +++ b/src/lib/components/FormattingToolbar.svelte @@ -62,11 +62,15 @@ noteSearchResults = []; return; } - const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); - if (requestId !== noteSearchRequestId) return; - if (!res.ok) return; - const results: { id: string; title: string }[] = await res.json(); - noteSearchResults = results.filter((n) => n.id !== currentNoteId); + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); + if (requestId !== noteSearchRequestId) return; + if (!res.ok) return; + const results: { id: string; title: string }[] = await res.json(); + noteSearchResults = results.filter((n) => n.id !== currentNoteId); + } catch { + // Search failed silently + } } let noteSearchRequestId = 0; @@ -115,6 +119,7 @@ function closeDropdowns() { openDropdown = null; dropdownAnchorEl = null; + clearTimeout(noteSearchTimer); noteSearchResults = []; } diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index 3573a86..404e79e 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -3,7 +3,7 @@