fix: offline-first loading from IndexedDB when server is down
- Remove blocking await adapter.whenReady() that prevented offline mode - Load from IndexedDB immediately without waiting for network - Set handle and mark as ready BEFORE network sync for instant UI - Background server sync with 5-second timeout - Continue with local data if network is unavailable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d54ceeb8e3
commit
5125cd9e3a
|
|
@ -312,11 +312,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
|
|
||||||
const initializeHandle = async () => {
|
const initializeHandle = async () => {
|
||||||
try {
|
try {
|
||||||
// CRITICAL: Wait for the network adapter to be ready before creating document
|
// OFFLINE-FIRST: Load from IndexedDB immediately, don't wait for network
|
||||||
// This ensures the WebSocket connection is established for sync
|
// Network sync happens in the background after local data is loaded
|
||||||
await adapter.whenReady()
|
|
||||||
|
|
||||||
if (!mounted) return
|
|
||||||
|
|
||||||
let handle: DocHandle<TLStoreSnapshot>
|
let handle: DocHandle<TLStoreSnapshot>
|
||||||
let loadedFromLocal = false
|
let loadedFromLocal = false
|
||||||
|
|
@ -354,7 +351,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
|
||||||
if (localRecordCount > 0) {
|
if (localRecordCount > 0) {
|
||||||
console.log(`Loaded document from IndexedDB: ${localRecordCount} records, ${localShapeCount} shapes`)
|
console.log(`📦 Loaded document from IndexedDB: ${localRecordCount} records, ${localShapeCount} shapes`)
|
||||||
|
|
||||||
// CRITICAL: Migrate local IndexedDB data to fix any invalid indices
|
// CRITICAL: Migrate local IndexedDB data to fix any invalid indices
|
||||||
// This ensures shapes with old-format indices like "b1" are fixed
|
// This ensures shapes with old-format indices like "b1" are fixed
|
||||||
|
|
@ -394,107 +391,144 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
||||||
|
|
||||||
if (!mounted) return
|
if (!mounted) return
|
||||||
|
|
||||||
// Sync with server to get latest data (or upload local changes if offline was edited)
|
// OFFLINE-FIRST: Set the handle and mark as ready BEFORE network sync
|
||||||
// This ensures we're in sync even if we loaded from IndexedDB
|
// This allows the UI to render immediately with local data
|
||||||
try {
|
|
||||||
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
|
||||||
if (response.ok) {
|
|
||||||
let serverDoc = await response.json() as TLStoreSnapshot
|
|
||||||
|
|
||||||
// Migrate server data to fix any invalid indices
|
|
||||||
if (serverDoc.store) {
|
|
||||||
serverDoc = {
|
|
||||||
...serverDoc,
|
|
||||||
store: migrateStoreData(serverDoc.store)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
|
||||||
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
|
||||||
|
|
||||||
// Get current local state
|
|
||||||
const localDoc = handle.doc()
|
|
||||||
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
|
||||||
|
|
||||||
// Merge server data with local data
|
|
||||||
// Strategy:
|
|
||||||
// 1. If local is EMPTY, use server data (bootstrap from R2)
|
|
||||||
// 2. If local HAS data, only add server records that don't exist locally
|
|
||||||
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
|
|
||||||
if (serverDoc.store && serverRecordCount > 0) {
|
|
||||||
handle.change((doc: any) => {
|
|
||||||
// Initialize store if it doesn't exist
|
|
||||||
if (!doc.store) {
|
|
||||||
doc.store = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const localIsEmpty = Object.keys(doc.store).length === 0
|
|
||||||
let addedFromServer = 0
|
|
||||||
let skippedExisting = 0
|
|
||||||
|
|
||||||
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
|
||||||
if (localIsEmpty) {
|
|
||||||
// Local is empty - bootstrap everything from server
|
|
||||||
doc.store[id] = record
|
|
||||||
addedFromServer++
|
|
||||||
} else if (!doc.store[id]) {
|
|
||||||
// Local has data but missing this record - add from server
|
|
||||||
// This handles: shapes created on another device and synced to R2
|
|
||||||
doc.store[id] = record
|
|
||||||
addedFromServer++
|
|
||||||
} else {
|
|
||||||
// Record exists locally - preserve local version
|
|
||||||
// The Automerge binary sync will handle merging conflicts via CRDT
|
|
||||||
// This preserves offline edits to existing shapes
|
|
||||||
skippedExisting++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`📥 Merge strategy: local was ${localIsEmpty ? 'EMPTY' : 'populated'}, added ${addedFromServer} from server, preserved ${skippedExisting} local records`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const finalDoc = handle.doc()
|
|
||||||
const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
|
||||||
console.log(`Merged server data: server had ${serverRecordCount}, local had ${localRecordCount}, final has ${finalRecordCount} records`)
|
|
||||||
} else if (!loadedFromLocal) {
|
|
||||||
// Server is empty and we didn't load from local - fresh start
|
|
||||||
console.log(`Starting fresh - no data on server or locally`)
|
|
||||||
}
|
|
||||||
} else if (response.status === 404) {
|
|
||||||
// No document found on server
|
|
||||||
if (loadedFromLocal) {
|
|
||||||
console.log(`No server document, but loaded ${handle.doc()?.store ? Object.keys(handle.doc()!.store).length : 0} records from local storage`)
|
|
||||||
} else {
|
|
||||||
console.log(`No document found on server - starting fresh`)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Network error - continue with local data if available
|
|
||||||
if (loadedFromLocal) {
|
|
||||||
console.log(`Offline mode: using local data from IndexedDB`)
|
|
||||||
} else {
|
|
||||||
console.error("Error loading from server (offline?):", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify final document state
|
|
||||||
const finalDoc = handle.doc() as any
|
|
||||||
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
|
||||||
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
|
||||||
console.log(`Automerge handle ready: ${finalStoreKeys} records, ${finalShapeCount} shapes (loaded from ${loadedFromLocal ? 'IndexedDB' : 'server/new'})`)
|
|
||||||
|
|
||||||
// CRITICAL: Set the documentId on the adapter BEFORE setHandle
|
|
||||||
// This ensures the adapter can properly route incoming binary sync messages
|
|
||||||
// The server may send sync messages immediately after connection, before we send anything
|
|
||||||
if (handle.url) {
|
if (handle.url) {
|
||||||
adapter.setDocumentId(handle.url)
|
adapter.setDocumentId(handle.url)
|
||||||
console.log(`📋 Set documentId on adapter: ${handle.url}`)
|
console.log(`📋 Set documentId on adapter: ${handle.url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
setHandle(handle)
|
// If we loaded from local, set handle immediately so UI can render
|
||||||
setIsLoading(false)
|
if (loadedFromLocal) {
|
||||||
|
const localDoc = handle.doc() as any
|
||||||
|
const localShapeCount = localDoc?.store ? Object.values(localDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
console.log(`📴 Offline-ready: ${localShapeCount} shapes available from IndexedDB`)
|
||||||
|
setHandle(handle)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync with server in the background (non-blocking for offline-first)
|
||||||
|
// This runs in parallel - if it fails, we still have local data
|
||||||
|
const syncWithServer = async () => {
|
||||||
|
try {
|
||||||
|
// Wait for network adapter with a timeout
|
||||||
|
const networkReadyPromise = adapter.whenReady()
|
||||||
|
const timeoutPromise = new Promise<'timeout'>((resolve) =>
|
||||||
|
setTimeout(() => resolve('timeout'), 5000)
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await Promise.race([networkReadyPromise, timeoutPromise])
|
||||||
|
|
||||||
|
if (result === 'timeout') {
|
||||||
|
console.log(`⏱️ Network adapter timeout - continuing in offline mode`)
|
||||||
|
// If we haven't set the handle yet (no local data), set it now
|
||||||
|
if (!loadedFromLocal && mounted) {
|
||||||
|
setHandle(handle)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return
|
||||||
|
|
||||||
|
const response = await fetch(`${workerUrl}/room/${roomId}`)
|
||||||
|
if (response.ok) {
|
||||||
|
let serverDoc = await response.json() as TLStoreSnapshot
|
||||||
|
|
||||||
|
// Migrate server data to fix any invalid indices
|
||||||
|
if (serverDoc.store) {
|
||||||
|
serverDoc = {
|
||||||
|
...serverDoc,
|
||||||
|
store: migrateStoreData(serverDoc.store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
const serverRecordCount = Object.keys(serverDoc.store || {}).length
|
||||||
|
|
||||||
|
// Get current local state
|
||||||
|
const localDoc = handle.doc()
|
||||||
|
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
|
||||||
|
|
||||||
|
// Merge server data with local data
|
||||||
|
// Strategy:
|
||||||
|
// 1. If local is EMPTY, use server data (bootstrap from R2)
|
||||||
|
// 2. If local HAS data, only add server records that don't exist locally
|
||||||
|
// (preserve offline changes, let Automerge CRDT sync handle conflicts)
|
||||||
|
if (serverDoc.store && serverRecordCount > 0) {
|
||||||
|
handle.change((doc: any) => {
|
||||||
|
// Initialize store if it doesn't exist
|
||||||
|
if (!doc.store) {
|
||||||
|
doc.store = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localIsEmpty = Object.keys(doc.store).length === 0
|
||||||
|
let addedFromServer = 0
|
||||||
|
let skippedExisting = 0
|
||||||
|
|
||||||
|
Object.entries(serverDoc.store).forEach(([id, record]) => {
|
||||||
|
if (localIsEmpty) {
|
||||||
|
// Local is empty - bootstrap everything from server
|
||||||
|
doc.store[id] = record
|
||||||
|
addedFromServer++
|
||||||
|
} else if (!doc.store[id]) {
|
||||||
|
// Local has data but missing this record - add from server
|
||||||
|
// This handles: shapes created on another device and synced to R2
|
||||||
|
doc.store[id] = record
|
||||||
|
addedFromServer++
|
||||||
|
} else {
|
||||||
|
// Record exists locally - preserve local version
|
||||||
|
// The Automerge binary sync will handle merging conflicts via CRDT
|
||||||
|
// This preserves offline edits to existing shapes
|
||||||
|
skippedExisting++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`📥 Merge strategy: local was ${localIsEmpty ? 'EMPTY' : 'populated'}, added ${addedFromServer} from server, preserved ${skippedExisting} local records`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalDoc = handle.doc()
|
||||||
|
const finalRecordCount = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||||
|
console.log(`🔄 Merged server data: server had ${serverRecordCount}, local had ${localRecordCount}, final has ${finalRecordCount} records`)
|
||||||
|
} else if (!loadedFromLocal) {
|
||||||
|
// Server is empty and we didn't load from local - fresh start
|
||||||
|
console.log(`Starting fresh - no data on server or locally`)
|
||||||
|
}
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
// No document found on server
|
||||||
|
if (loadedFromLocal) {
|
||||||
|
console.log(`No server document, but loaded ${handle.doc()?.store ? Object.keys(handle.doc()!.store).length : 0} records from local storage`)
|
||||||
|
} else {
|
||||||
|
console.log(`No document found on server - starting fresh`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Failed to load document from server: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Network error - continue with local data if available
|
||||||
|
if (loadedFromLocal) {
|
||||||
|
console.log(`📴 Offline mode: using local data from IndexedDB`)
|
||||||
|
} else {
|
||||||
|
console.error("Error loading from server (offline?):", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify final document state
|
||||||
|
const finalDoc = handle.doc() as any
|
||||||
|
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
|
||||||
|
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
console.log(`✅ Automerge handle ready: ${finalStoreKeys} records, ${finalShapeCount} shapes (loaded from ${loadedFromLocal ? 'IndexedDB' : 'server/new'})`)
|
||||||
|
|
||||||
|
// If we haven't set the handle yet (no local data), set it now after server sync
|
||||||
|
if (!loadedFromLocal && mounted) {
|
||||||
|
setHandle(handle)
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start server sync in background (don't await - non-blocking)
|
||||||
|
syncWithServer()
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error initializing Automerge handle:", error)
|
console.error("Error initializing Automerge handle:", error)
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue