forked from mirror/crumbs-original
fix: prevent checklist state loss from sync race condition
Two issues caused checked items to revert after closing: 1. loadNotes() blindly overwrote the store with notes.set(), so a stale response from the periodic sync (every 30s) could replace a just-saved note with older server data. Now merges by version number, keeping the higher-versioned note. 2. Checklist component ignored external content prop changes (from sync or history restore). Added $effect that re-parses items when the content prop changes from an external source, using lastEmitted tracking to distinguish self-originated vs external updates. https://claude.ai/code/session_01TdPpATUkmdY7DhBkmqrvWs
This commit is contained in:
@@ -24,6 +24,18 @@
|
||||
let doneExpanded = $state(true);
|
||||
const flipDurationMs = 150;
|
||||
|
||||
// Track the last content we emitted so we can distinguish self-originated
|
||||
// changes from external ones (sync, history restore, etc.)
|
||||
let lastEmitted = content;
|
||||
|
||||
// Re-parse items when content changes externally (not from our own emitChange)
|
||||
$effect(() => {
|
||||
if (content !== lastEmitted) {
|
||||
items = parseChecklist(content);
|
||||
lastEmitted = content;
|
||||
}
|
||||
});
|
||||
|
||||
let activeItems = $derived(items.filter((i) => !i.checked));
|
||||
let doneItems = $derived(items.filter((i) => i.checked));
|
||||
let doneCount = $derived(doneItems.length);
|
||||
@@ -49,7 +61,9 @@
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
onChange(serializeChecklist(items));
|
||||
const serialized = serializeChecklist(items);
|
||||
lastEmitted = serialized;
|
||||
onChange(serialized);
|
||||
}
|
||||
|
||||
function toggleItem(id: string) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeNotesByVersion } from './notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
return {
|
||||
id: 'note-1',
|
||||
title: 'Test',
|
||||
content: '- [ ] Item',
|
||||
color: 'default',
|
||||
pinned: false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
version: 1,
|
||||
tags: [],
|
||||
attachments: [],
|
||||
collaborators: [],
|
||||
isOwner: true,
|
||||
isShared: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeNotesByVersion', () => {
|
||||
it('keeps the local note when it has a higher version than the incoming note', () => {
|
||||
const local = makeNote({ id: 'n1', version: 3, content: '- [x] Checked' });
|
||||
const incoming = makeNote({ id: 'n1', version: 2, content: '- [ ] Unchecked' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('- [x] Checked');
|
||||
expect(result[0].version).toBe(3);
|
||||
});
|
||||
|
||||
it('uses the incoming note when it has a higher or equal version', () => {
|
||||
const local = makeNote({ id: 'n1', version: 2, content: '- [ ] Old' });
|
||||
const incoming = makeNote({ id: 'n1', version: 3, content: '- [x] New' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].content).toBe('- [x] New');
|
||||
expect(result[0].version).toBe(3);
|
||||
});
|
||||
|
||||
it('uses the incoming note when versions are equal', () => {
|
||||
const local = makeNote({ id: 'n1', version: 2, content: 'local' });
|
||||
const incoming = makeNote({ id: 'n1', version: 2, content: 'incoming' });
|
||||
|
||||
const result = mergeNotesByVersion([local], [incoming]);
|
||||
|
||||
expect(result[0].content).toBe('incoming');
|
||||
});
|
||||
|
||||
it('handles notes not present in the local store', () => {
|
||||
const incoming = makeNote({ id: 'n2', version: 1 });
|
||||
|
||||
const result = mergeNotesByVersion([], [incoming]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('n2');
|
||||
});
|
||||
|
||||
it('preserves multiple notes correctly during a race condition', () => {
|
||||
const localNotes = [
|
||||
makeNote({ id: 'n1', version: 5, content: '- [x] Just saved' }),
|
||||
makeNote({ id: 'n2', version: 1, content: 'unchanged' })
|
||||
];
|
||||
const incomingNotes = [
|
||||
makeNote({ id: 'n1', version: 4, content: '- [ ] Stale from server' }),
|
||||
makeNote({ id: 'n2', version: 2, content: 'updated on server' })
|
||||
];
|
||||
|
||||
const result = mergeNotesByVersion(localNotes, incomingNotes);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// n1: local version 5 > incoming version 4 → keep local
|
||||
expect(result[0].content).toBe('- [x] Just saved');
|
||||
// n2: local version 1 < incoming version 2 → use incoming
|
||||
expect(result[1].content).toBe('updated on server');
|
||||
});
|
||||
});
|
||||
+17
-2
@@ -53,12 +53,27 @@ function isNetworkError(err: unknown): boolean {
|
||||
return err instanceof TypeError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge incoming notes with current store, keeping the higher-version note
|
||||
* when a conflict exists. This prevents a stale loadNotes response from
|
||||
* overwriting a just-saved note (race between sync interval and updateNote).
|
||||
*/
|
||||
export function mergeNotesByVersion(current: Note[], incoming: Note[]): Note[] {
|
||||
const currentMap = new Map(current.map((n) => [n.id, n]));
|
||||
const merged = incoming.map((incomingNote) => {
|
||||
const local = currentMap.get(incomingNote.id);
|
||||
if (local && local.version > incomingNote.version) return local;
|
||||
return incomingNote;
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
// Load from IDB first for instant display
|
||||
try {
|
||||
const cached = await getAllNotes();
|
||||
if (cached.length > 0) {
|
||||
notes.set(cached);
|
||||
notes.update((current) => current.length > 0 ? mergeNotesByVersion(current, cached) : cached);
|
||||
}
|
||||
} catch {
|
||||
// IDB unavailable — continue with server fetch
|
||||
@@ -71,7 +86,7 @@ export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data: Note[] = await res.json();
|
||||
notes.set(data);
|
||||
notes.update((current) => mergeNotesByVersion(current, data));
|
||||
currentFilter.set(filter);
|
||||
|
||||
// Update IDB with server state
|
||||
|
||||
Reference in New Issue
Block a user