forked from mirror/crumbs-original
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:
co-authored by
Claude Opus 4.6
parent
6269a9ac99
commit
71befde16e
+21
-12
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user