Skip to content

Commit 8aeeaa1

Browse files
committed
fix(mobile): implement optimistic UI updates and fix cache staleness issues
1 parent 453273b commit 8aeeaa1

File tree

5 files changed

+167
-20
lines changed

5 files changed

+167
-20
lines changed

apps/mobile/v1/src/screens/EditNote/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import * as Haptics from 'expo-haptics';
33
import { useLocalSearchParams,useRouter } from 'expo-router';
44
import { Code,Quote, Redo, Undo } from 'lucide-react-native';
55
import React, { useEffect, useMemo,useRef, useState } from 'react';
6-
import { Alert, Keyboard,ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
6+
import { Alert, DeviceEventEmitter,Keyboard,ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
77
import { SafeAreaView } from 'react-native-safe-area-context';
88

99
import { Editor, type EditorColors,type EditorRef } from '@/editor/src';
@@ -160,6 +160,9 @@ export default function EditNoteScreen() {
160160
logger.info('[NOTE] Note updated successfully', {
161161
attributes: { noteId: currentNoteId, title: titleToUse }
162162
});
163+
164+
// Emit event for optimistic UI update in notes list
165+
DeviceEventEmitter.emit('noteUpdated', savedNote);
163166
} else {
164167
logger.info('[NOTE] Creating new note', {
165168
attributes: { title: titleToUse, contentLength: htmlContent.length, folderId: folderId as string | undefined }
@@ -178,6 +181,9 @@ export default function EditNoteScreen() {
178181
logger.info('[NOTE] Note created successfully', {
179182
attributes: { noteId: savedNote.id, title: titleToUse }
180183
});
184+
185+
// Emit event for optimistic UI update in notes list
186+
DeviceEventEmitter.emit('noteCreated', savedNote);
181187
}
182188

183189
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
@@ -271,6 +277,9 @@ export default function EditNoteScreen() {
271277
attributes: { noteId: noteId as string }
272278
});
273279

280+
// Emit event for optimistic UI update in notes list
281+
DeviceEventEmitter.emit('noteDeleted', noteId as string);
282+
274283
// Navigate back to the folder/notes list, skipping the view-note screen
275284
if (router.canGoBack()) {
276285
router.back(); // Go back from edit screen

apps/mobile/v1/src/screens/FolderNotes/components/NotesList/index.tsx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
44
import { useFocusEffect } from '@react-navigation/native';
55
import { useRouter } from 'expo-router';
66
import React, { useCallback,useEffect, useMemo, useRef, useState } from 'react';
7-
import { ActivityIndicator, Alert, Animated, FlatList, RefreshControl, StyleSheet, View } from 'react-native';
7+
import { ActivityIndicator, Alert, Animated, DeviceEventEmitter,FlatList, RefreshControl, StyleSheet, View } from 'react-native';
88

99
import { type Folder, type Note, useApiService } from '@/src/services/api';
1010
import { useTheme } from '@/src/theme';
@@ -87,9 +87,63 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
8787
const localScrollY = useRef(new Animated.Value(0)).current;
8888
const scrollY = parentScrollY || localScrollY;
8989

90+
// Track last optimistic update to prevent immediate reload from overwriting it
91+
const lastOptimisticUpdateRef = useRef<number>(0);
92+
93+
// Listen for note creation/update/delete events for optimistic UI updates
94+
useEffect(() => {
95+
const createdSubscription = DeviceEventEmitter.addListener('noteCreated', (note: Note) => {
96+
// Optimistically add the note to the list immediately
97+
setNotes(prevNotes => {
98+
// Check if note already exists (avoid duplicates)
99+
if (prevNotes.some(n => n.id === note.id)) {
100+
return prevNotes;
101+
}
102+
// Add note to the beginning of the list
103+
return [note, ...prevNotes];
104+
});
105+
// Track that we just did an optimistic update
106+
lastOptimisticUpdateRef.current = Date.now();
107+
});
108+
109+
const updatedSubscription = DeviceEventEmitter.addListener('noteUpdated', (note: Note) => {
110+
// Optimistically update the note in the list immediately
111+
setNotes(prevNotes => {
112+
return prevNotes.map(n => n.id === note.id ? note : n);
113+
});
114+
// Track that we just did an optimistic update
115+
lastOptimisticUpdateRef.current = Date.now();
116+
});
117+
118+
const deletedSubscription = DeviceEventEmitter.addListener('noteDeleted', (noteId: string) => {
119+
// Optimistically remove the note from the list immediately
120+
setNotes(prevNotes => {
121+
return prevNotes.filter(n => n.id !== noteId);
122+
});
123+
// Track that we just did an optimistic update
124+
lastOptimisticUpdateRef.current = Date.now();
125+
});
126+
127+
return () => {
128+
createdSubscription.remove();
129+
updatedSubscription.remove();
130+
deletedSubscription.remove();
131+
};
132+
}, [setNotes]);
133+
90134
// Load notes when screen focuses or params change
91135
useFocusEffect(
92136
React.useCallback(() => {
137+
// Skip reload if we just did an optimistic update (within 500ms)
138+
const timeSinceOptimisticUpdate = Date.now() - lastOptimisticUpdateRef.current;
139+
if (timeSinceOptimisticUpdate < 500) {
140+
if (__DEV__) {
141+
console.log('[NotesList] Skipping reload - just did optimistic update');
142+
}
143+
loadViewMode();
144+
return;
145+
}
146+
93147
loadNotes();
94148
loadViewMode();
95149
// Reset scroll position when screen comes into focus
@@ -136,9 +190,32 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
136190
text: 'Empty Trash',
137191
style: 'destructive',
138192
onPress: async () => {
193+
// Save current notes in case we need to restore on error
194+
const previousNotes = [...notes];
195+
139196
try {
197+
if (__DEV__) {
198+
console.log(`[NotesList] Empty trash - clearing ${deletedNotes.length} notes optimistically`);
199+
}
200+
201+
// Optimistically clear all deleted notes from the list immediately
202+
setNotes(prevNotes => {
203+
const filtered = prevNotes.filter(n => !n.deleted);
204+
if (__DEV__) {
205+
console.log(`[NotesList] After filter: ${prevNotes.length} -> ${filtered.length} notes`);
206+
}
207+
return filtered;
208+
});
209+
lastOptimisticUpdateRef.current = Date.now();
210+
140211
// Empty the trash using the API
212+
if (__DEV__) {
213+
console.log(`[NotesList] Calling api.emptyTrash()`);
214+
}
141215
const result = await api.emptyTrash();
216+
if (__DEV__) {
217+
console.log(`[NotesList] api.emptyTrash() completed - ${result.deletedCount} deleted`);
218+
}
142219

143220
// Navigate to main folders screen
144221
router.replace('/');
@@ -148,6 +225,8 @@ export default function NotesList({ navigation, route, renderHeader, scrollY: pa
148225
Alert.alert('Success', `${result.deletedCount} notes permanently deleted.`);
149226
}, 300);
150227
} catch (error) {
228+
// Restore notes on error
229+
setNotes(previousNotes);
151230
if (__DEV__) console.error('Failed to empty trash:', error);
152231
Alert.alert('Error', 'Failed to empty trash. Please try again.');
153232
}

apps/mobile/v1/src/screens/Settings/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export default function SettingsScreen({ onLogout }: Props) {
163163

164164
// Clear SQLite cached data
165165
await clearCachedFolders();
166-
await clearCachedNotes();
166+
await clearCachedNotes(true); // Clear ALL notes including unsynced
167167
await clearAllCacheMetadata();
168168

169169
// Clear decrypted cache
@@ -202,7 +202,7 @@ export default function SettingsScreen({ onLogout }: Props) {
202202

203203
// Clear SQLite cached data
204204
await clearCachedFolders();
205-
await clearCachedNotes();
205+
await clearCachedNotes(true); // Clear ALL notes including unsynced
206206
await clearAllCacheMetadata();
207207

208208
// Clear decrypted cache

apps/mobile/v1/src/services/api/databaseCache.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -448,15 +448,22 @@ export async function clearCachedFolders(): Promise<void> {
448448
* Called when clearing all caches from settings
449449
* IMPORTANT: Preserves unsynced notes to prevent data loss
450450
*/
451-
export async function clearCachedNotes(): Promise<void> {
451+
export async function clearCachedNotes(clearAll = false): Promise<void> {
452452
try {
453453
const db = getDatabase();
454454

455-
// Only delete synced notes to preserve unsynced offline changes
456-
await db.runAsync(`DELETE FROM notes WHERE is_synced = 1`);
457-
458-
if (__DEV__) {
459-
console.log('[DatabaseCache] Cleared synced cached notes (preserved unsynced notes)');
455+
if (clearAll) {
456+
// Clear ALL notes (used when user manually clears cache)
457+
await db.runAsync(`DELETE FROM notes`);
458+
if (__DEV__) {
459+
console.log('[DatabaseCache] Cleared ALL cached notes (including unsynced)');
460+
}
461+
} else {
462+
// Only delete synced notes to preserve unsynced offline changes
463+
await db.runAsync(`DELETE FROM notes WHERE is_synced = 1`);
464+
if (__DEV__) {
465+
console.log('[DatabaseCache] Cleared synced cached notes (preserved unsynced notes)');
466+
}
460467
}
461468
} catch (error) {
462469
if (error instanceof Error && error.message.includes('Database not initialized')) {

apps/mobile/v1/src/services/api/notes.ts

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,26 @@ export function createNotesApi(getToken: AuthTokenGetter, getUserId: () => strin
4242
// Invalidate database cache metadata so next fetch will get fresh data
4343
await invalidateCache('notes');
4444

45-
// Clear cached notes from SQLite, but preserve unsynced temp notes
46-
// This ensures next fetch shows updated notes while keeping offline edits
47-
try {
48-
const db = getDatabase();
49-
// Only delete synced notes (is_synced = 1) to preserve temp/unsynced notes
50-
await db.runAsync('DELETE FROM notes WHERE is_synced = 1');
51-
if (__DEV__) {
52-
console.log('[API] Cleared synced cached notes from database (preserved unsynced temp notes)');
45+
// Only clear cached notes when ONLINE - offline we need to keep them
46+
// because we can't refetch them
47+
if (navigator.onLine) {
48+
// Clear cached notes from SQLite, but preserve unsynced temp notes
49+
// This ensures next fetch shows updated notes while keeping offline edits
50+
try {
51+
const db = getDatabase();
52+
// Only delete synced notes (is_synced = 1) to preserve temp/unsynced notes
53+
await db.runAsync('DELETE FROM notes WHERE is_synced = 1');
54+
if (__DEV__) {
55+
console.log('[API] Cleared synced cached notes from database (preserved unsynced temp notes)');
56+
}
57+
} catch (error) {
58+
if (__DEV__) {
59+
console.error('[API] Failed to clear cached notes:', error);
60+
}
5361
}
54-
} catch (error) {
62+
} else {
5563
if (__DEV__) {
56-
console.error('[API] Failed to clear cached notes:', error);
64+
console.log('[API] Skipped clearing cached notes - offline mode');
5765
}
5866
}
5967
};
@@ -377,6 +385,37 @@ export function createNotesApi(getToken: AuthTokenGetter, getUserId: () => strin
377385
(response) => response.notes || []
378386
);
379387

388+
// IMPORTANT: Clear old cached notes matching this filter before storing new ones
389+
// This ensures deleted notes on server are removed from cache
390+
try {
391+
const db = getDatabase();
392+
if (params?.deleted !== undefined) {
393+
await db.runAsync('DELETE FROM notes WHERE deleted = ?', [params.deleted ? 1 : 0]);
394+
if (__DEV__) {
395+
console.log(`[API] Cleared cached notes with deleted=${params.deleted} before refresh`);
396+
}
397+
} else if (params?.archived !== undefined) {
398+
await db.runAsync('DELETE FROM notes WHERE archived = ? AND deleted = 0', [params.archived ? 1 : 0]);
399+
if (__DEV__) {
400+
console.log(`[API] Cleared cached notes with archived=${params.archived} before refresh`);
401+
}
402+
} else if (params?.starred !== undefined) {
403+
await db.runAsync('DELETE FROM notes WHERE starred = ? AND deleted = 0 AND archived = 0', [params.starred ? 1 : 0]);
404+
if (__DEV__) {
405+
console.log(`[API] Cleared cached notes with starred=${params.starred} before refresh`);
406+
}
407+
} else if (params?.folderId !== undefined) {
408+
await db.runAsync('DELETE FROM notes WHERE folder_id = ? AND deleted = 0', [params.folderId]);
409+
if (__DEV__) {
410+
console.log(`[API] Cleared cached notes in folder ${params.folderId} before refresh`);
411+
}
412+
}
413+
} catch (error) {
414+
if (__DEV__) {
415+
console.error('[API] Failed to clear filtered cached notes:', error);
416+
}
417+
}
418+
380419
// Check if user wants decrypted content cached for instant loading
381420
const cacheDecrypted = await getCacheDecryptedContentPreference();
382421

@@ -983,6 +1022,19 @@ export function createNotesApi(getToken: AuthTokenGetter, getUserId: () => strin
9831022
method: 'DELETE',
9841023
});
9851024

1025+
// Delete all deleted notes from SQLite cache
1026+
try {
1027+
const db = getDatabase();
1028+
await db.runAsync('DELETE FROM notes WHERE deleted = 1');
1029+
if (__DEV__) {
1030+
console.log('[API] Cleared deleted notes from cache after empty trash');
1031+
}
1032+
} catch (error) {
1033+
if (__DEV__) {
1034+
console.error('[API] Failed to clear deleted notes from cache:', error);
1035+
}
1036+
}
1037+
9861038
// Invalidate counts cache
9871039
invalidateCountsCache();
9881040

0 commit comments

Comments
 (0)