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:
Claude
2026-03-21 20:27:38 +01:00
committed by Fred Cabassut
parent 7af65c94db
commit 8d8bd6a790
4 changed files with 140 additions and 3 deletions
+15 -1
View File
@@ -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) {
+88
View File
@@ -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
View File
@@ -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
+20
View File
@@ -60,6 +60,26 @@ test.describe('Checklist', () => {
await expect(page.getByTestId('checklist-done-checkbox').first()).toBeChecked();
});
test('Scenario: Multiple checked items persist after closing and reopening', async ({ authenticatedPage: page }) => {
// Given a checklist note with three items exists
await createChecklistNote(page, 'Groceries', ['Milk', 'Eggs', 'Bread']);
// When the user reopens and checks two items
await noteCard(page, 'Groceries').click();
await page.getByTestId('checklist-checkbox').nth(0).click(); // Milk → done
await page.getByTestId('checklist-checkbox').nth(0).click(); // Eggs (now first) → done
// Then the done section shows two checked items
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
await page.getByTestId('close-editor-btn').click();
// And the checked state persists after reopening
await noteCard(page, 'Groceries').click();
await expect(page.getByTestId('checklist-toggle-done')).toContainText('2 done');
await expect(page.getByTestId('checklist-input')).toHaveCount(1); // Only Bread remains active
await expect(page.getByTestId('checklist-input').first()).toHaveValue('Bread');
});
test('Scenario: Enter key adds a new checklist item', async ({ authenticatedPage: page }) => {
// Given a checklist with one item
await page.getByTestId('new-note-btn').click();