From 71befde16ea3b972e1b300df81f1e6b7bb947495 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Fri, 6 Mar 2026 21:28:23 +0100 Subject: [PATCH] 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 --- src/lib/stores/notes.ts | 33 +++++++++++++++++++++------------ src/lib/sync/client.ts | 11 +++-------- tests/e2e/offline.spec.ts | 17 +++++++++++++++++ 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/lib/stores/notes.ts b/src/lib/stores/notes.ts index a5d17d4..763022f 100644 --- a/src/lib/stores/notes.ts +++ b/src/lib/stores/notes.ts @@ -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 { 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 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 { 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; diff --git a/src/lib/sync/client.ts b/src/lib/sync/client.ts index 3c80cb8..e3dfe63 100644 --- a/src/lib/sync/client.ts +++ b/src/lib/sync/client.ts @@ -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 { 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'); } diff --git a/tests/e2e/offline.spec.ts b/tests/e2e/offline.spec.ts index 8f17529..7667e55 100644 --- a/tests/e2e/offline.spec.ts +++ b/tests/e2e/offline.spec.ts @@ -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();