feat: offline-first note loading from IDB cache

Load notes from IndexedDB first for instant display, then sync from
server in background. All mutations now persist to IDB when online,
keeping the cache always current. Eliminates empty state flash on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-06 23:12:36 +01:00
co-authored by Claude Opus 4.6
parent 6269a9ac99
commit 71befde16e
3 changed files with 41 additions and 20 deletions
+21 -12
View File
@@ -1,6 +1,6 @@
import { writable, derived } from 'svelte/store';
import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js';
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes } from '$lib/sync/idb.js';
import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js';
import { showToast } from '$lib/stores/toast.js';
import { extractTags } from '$lib/utils/tags.js';
@@ -45,21 +45,27 @@ function isNetworkError(err: unknown): boolean {
}
export async function loadNotes(filter: NoteFilter = 'all') {
// Load from IDB first for instant display
const cached = await getAllNotes();
if (cached.length > 0) {
notes.set(cached);
currentFilter.set(filter);
}
// Then fetch from server in the background
try {
const res = await fetch(`/api/notes?filter=${filter}`);
if (res.ok) {
const data = await res.json();
const data: Note[] = await res.json();
notes.set(data);
currentFilter.set(filter);
// Update IDB with server state
await clearIdbNotes();
await Promise.all(data.map((n) => putNote(n)));
}
} catch (err) {
if (isNetworkError(err)) {
const cached = await getAllNotes();
if (cached.length > 0) {
notes.set(cached);
currentFilter.set(filter);
}
}
} catch {
// Offline — IDB data already loaded above
}
}
@@ -71,8 +77,9 @@ export async function createNote(note: NoteCreate): Promise<Note | null> {
body: JSON.stringify(note)
});
if (res.ok) {
const created = await res.json();
const created: Note = await res.json();
notes.update((list) => [created, ...list]);
await putNote(created);
return created;
}
return null;
@@ -115,8 +122,9 @@ export async function updateNote(id: string, updates: NoteUpdate): Promise<Note
body: JSON.stringify(updates)
});
if (res.ok) {
const updated = await res.json();
const updated: Note = await res.json();
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
await putNote(updated);
return updated;
}
showToast('Failed to save note', 'error');
@@ -152,6 +160,7 @@ export async function deleteNote(id: string): Promise<boolean> {
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
if (res.ok) {
notes.update((list) => list.filter((n) => n.id !== id));
await deleteNoteFromIdb(id);
return true;
}
return false;
+3 -8
View File
@@ -2,7 +2,7 @@ import { browser } from '$app/environment';
import type { Note } from '$lib/types/index.js';
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
import { mergeNotes } from './crdt.js';
import { notes as notesStore, currentFilter } from '$lib/stores/notes.js';
import { loadNotes, currentFilter } from '$lib/stores/notes.js';
import { get } from 'svelte/store';
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
@@ -101,13 +101,8 @@ export async function sync(): Promise<void> {
await pullChanges();
setStatus('synced');
// Reload notes from server to reflect synced state
const filter = get(currentFilter);
const res = await fetch(`/api/notes?filter=${filter}`);
if (res.ok) {
const data = await res.json();
notesStore.set(data);
}
// Reload notes from server and persist to IDB
await loadNotes(get(currentFilter));
} catch {
setStatus('error');
}
+17
View File
@@ -79,6 +79,23 @@ test.describe('Offline Support', () => {
await expect(page.getByText('Survive Reload')).toBeVisible();
});
test('Scenario: Notes load from local cache when the API is unavailable', async ({ authenticatedPage: page }) => {
// Given a note titled "Cached Note" exists
await page.getByTestId('new-note-btn').click();
await page.getByTestId('note-title-input').fill('Cached Note');
await page.getByTestId('close-editor-btn').click();
await expect(page.getByText('Cached Note')).toBeVisible();
// When the API is unavailable (simulating service worker serving cached shell)
await page.route('**/api/**', (route) => route.abort());
// And the user reloads the page
await page.reload();
// Then the note is still visible (loaded from IDB cache)
await expect(page.getByText('Cached Note')).toBeVisible();
});
test('Scenario: Trashed note disappears from the list when offline', async ({ authenticatedPage: page }) => {
// Given a note titled "Offline Trash" exists
await page.getByTestId('new-note-btn').click();