feat: add offline storage fallback for browser reload

When the browser reloads without network connectivity, the canvas now
automatically loads from local IndexedDB storage and renders the last
known state.

Changes:
- Board.tsx: Updated render condition to allow rendering when offline
  with local data (isOfflineWithLocalData flag)
- useAutomergeStoreV2: Added isNetworkOnline parameter and offline fast
  path that immediately loads records from Automerge doc without waiting
  for network patches
- useAutomergeSyncRepo: Passes isNetworkOnline to useAutomergeStoreV2
- ConnectionStatusIndicator: Updated messaging to clarify users are
  viewing locally cached canvas when offline

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-14 23:57:26 -05:00
parent f06c5c7537
commit fafad35cb0
4 changed files with 202 additions and 51 deletions

View File

@ -136,10 +136,12 @@ export function useAutomergeStoreV2({
handle, handle,
userId: _userId, userId: _userId,
adapter, adapter,
isNetworkOnline = true,
}: { }: {
handle: DocHandle<any> handle: DocHandle<any>
userId: string userId: string
adapter?: any adapter?: any
isNetworkOnline?: boolean
}): TLStoreWithStatus { }): TLStoreWithStatus {
// useAutomergeStoreV2 initializing // useAutomergeStoreV2 initializing
@ -1074,58 +1076,122 @@ export function useAutomergeStoreV2({
try { try {
await handle.whenReady() await handle.whenReady()
const doc = handle.doc() const doc = handle.doc()
// Check if store is already populated from patches // Check if store is already populated from patches
const existingStoreRecords = store.allRecords() const existingStoreRecords = store.allRecords()
const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape') const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape')
// Determine connection status based on network state
const connectionStatus = isNetworkOnline ? "online" : "offline"
if (doc.store) { if (doc.store) {
const storeKeys = Object.keys(doc.store) const storeKeys = Object.keys(doc.store)
const docShapes = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length const docShapes = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
console.log(`📊 Patch-based initialization: doc has ${storeKeys.length} records (${docShapes} shapes), store has ${existingStoreRecords.length} records (${existingStoreShapes.length} shapes)`) console.log(`📊 Patch-based initialization: doc has ${storeKeys.length} records (${docShapes} shapes), store has ${existingStoreRecords.length} records (${existingStoreShapes.length} shapes), network: ${connectionStatus}`)
// If store already has shapes, patches have been applied (dev mode behavior) // If store already has shapes, patches have been applied (dev mode behavior)
if (existingStoreShapes.length > 0) { if (existingStoreShapes.length > 0) {
console.log(`✅ Store already populated from patches (${existingStoreShapes.length} shapes) - using patch-based loading like dev`) console.log(`✅ Store already populated from patches (${existingStoreShapes.length} shapes) - using patch-based loading like dev`)
// REMOVED: Aggressive shape refresh that was causing coordinate loss // REMOVED: Aggressive shape refresh that was causing coordinate loss
// Shapes should be visible through normal patch application // Shapes should be visible through normal patch application
// If shapes aren't visible, it's likely a different issue that refresh won't fix // If shapes aren't visible, it's likely a different issue that refresh won't fix
setStoreWithStatus({ setStoreWithStatus({
store, store,
status: "synced-remote", status: "synced-remote",
connectionStatus: "online", connectionStatus,
}) })
return return
} }
// OFFLINE FAST PATH: When offline with local data, load immediately
// Don't wait for patches that will never come from the network
if (!isNetworkOnline && docShapes > 0) {
console.log(`📴 Offline mode with ${docShapes} shapes in local storage - loading immediately`)
// Manually load data from Automerge doc since patches won't come through
try {
const allRecords: TLRecord[] = []
Object.entries(doc.store).forEach(([id, record]: [string, any]) => {
if (!record || !record.typeName || !record.id) return
if (record.typeName === 'obsidian_vault' || (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) return
try {
let cleanRecord: any
try {
cleanRecord = JSON.parse(JSON.stringify(record))
} catch {
cleanRecord = safeExtractPlainObject(record)
}
if (cleanRecord && typeof cleanRecord === 'object') {
const sanitized = sanitizeRecord(cleanRecord)
const plainSanitized = JSON.parse(JSON.stringify(sanitized))
allRecords.push(plainSanitized)
}
} catch (e) {
console.warn(`⚠️ Could not process record ${id}:`, e)
}
})
// Filter out SharedPiano shapes since they're no longer supported
const filteredRecords = allRecords.filter((record: any) => {
if (record.typeName === 'shape' && record.type === 'SharedPiano') {
return false
}
return true
})
if (filteredRecords.length > 0) {
console.log(`📴 Loading ${filteredRecords.length} records from offline storage`)
store.mergeRemoteChanges(() => {
const pageRecords = filteredRecords.filter(r => r.typeName === 'page')
const shapeRecords = filteredRecords.filter(r => r.typeName === 'shape')
const otherRecords = filteredRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape')
const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords]
store.put(recordsToAdd)
})
console.log(`✅ Offline data loaded: ${filteredRecords.filter(r => r.typeName === 'shape').length} shapes`)
}
} catch (error) {
console.error(`❌ Error loading offline data:`, error)
}
setStoreWithStatus({
store,
status: "synced-remote", // Use synced-remote so Board renders
connectionStatus: "offline",
})
return
}
// If doc has data but store doesn't, patches should have been generated when data was written // If doc has data but store doesn't, patches should have been generated when data was written
// The automergeChangeHandler (set up above) should process them automatically // The automergeChangeHandler (set up above) should process them automatically
// Just wait a bit for patches to be processed, then set status // Just wait a bit for patches to be processed, then set status
if (docShapes > 0 && existingStoreShapes.length === 0) { if (docShapes > 0 && existingStoreShapes.length === 0) {
console.log(`📊 Doc has ${docShapes} shapes but store is empty. Waiting for patches to be processed by handler...`) console.log(`📊 Doc has ${docShapes} shapes but store is empty. Waiting for patches to be processed by handler...`)
// Wait briefly for patches to be processed by automergeChangeHandler // Wait briefly for patches to be processed by automergeChangeHandler
// The handler is already set up, so it should catch patches from the initial data load // The handler is already set up, so it should catch patches from the initial data load
let attempts = 0 let attempts = 0
const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms) const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms)
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
const checkForPatches = () => { const checkForPatches = () => {
attempts++ attempts++
const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape') const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape')
if (currentShapes.length > 0) { if (currentShapes.length > 0) {
console.log(`✅ Patches applied successfully: ${currentShapes.length} shapes loaded via patches`) console.log(`✅ Patches applied successfully: ${currentShapes.length} shapes loaded via patches`)
// REMOVED: Aggressive shape refresh that was causing coordinate loss // REMOVED: Aggressive shape refresh that was causing coordinate loss
// Shapes loaded via patches should be visible without forced refresh // Shapes loaded via patches should be visible without forced refresh
setStoreWithStatus({ setStoreWithStatus({
store, store,
status: "synced-remote", status: "synced-remote",
connectionStatus: "online", connectionStatus,
}) })
resolve() resolve()
} else if (attempts < maxAttempts) { } else if (attempts < maxAttempts) {
@ -1136,35 +1202,35 @@ export function useAutomergeStoreV2({
console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`) console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`)
console.warn(`⚠️ This may happen if Automerge doc was initialized with server data before handler was ready.`) console.warn(`⚠️ This may happen if Automerge doc was initialized with server data before handler was ready.`)
console.warn(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`) console.warn(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`)
// Simplified fallback: Just log and continue with empty store // Simplified fallback: Just log and continue with empty store
// Patches should handle data loading, so if they don't come through, // Patches should handle data loading, so if they don't come through,
// it's likely the document is actually empty or there's a timing issue // it's likely the document is actually empty or there's a timing issue
// that will resolve on next sync // that will resolve on next sync
setStoreWithStatus({ setStoreWithStatus({
store, store,
status: "synced-remote", status: "synced-remote",
connectionStatus: "online", connectionStatus,
}) })
resolve() resolve()
} }
} }
// Start checking immediately since handler is already set up // Start checking immediately since handler is already set up
setTimeout(checkForPatches, 100) setTimeout(checkForPatches, 100)
}) })
return return
} }
// If doc is empty, just set status // If doc is empty, just set status
if (docShapes === 0) { if (docShapes === 0) {
console.log(`📊 Empty document - starting fresh (patch-based loading)`) console.log(`📊 Empty document - starting fresh (patch-based loading)`)
setStoreWithStatus({ setStoreWithStatus({
store, store,
status: "synced-remote", status: "synced-remote",
connectionStatus: "online", connectionStatus,
}) })
return return
} }
@ -1174,7 +1240,7 @@ export function useAutomergeStoreV2({
setStoreWithStatus({ setStoreWithStatus({
store, store,
status: "synced-remote", status: "synced-remote",
connectionStatus: "online", connectionStatus: isNetworkOnline ? "online" : "offline",
}) })
return return
} }
@ -1183,17 +1249,17 @@ export function useAutomergeStoreV2({
setStoreWithStatus({ setStoreWithStatus({
store, store,
status: "synced-remote", status: "synced-remote",
connectionStatus: "online", connectionStatus: isNetworkOnline ? "online" : "offline",
}) })
} }
} }
initializeStore() initializeStore()
return () => { return () => {
unsubs.forEach((unsub) => unsub()) unsubs.forEach((unsub) => unsub())
} }
}, [handle, store]) }, [handle, store, isNetworkOnline])
/* -------------------- Presence -------------------- */ /* -------------------- Presence -------------------- */
// Create a safe handle that won't cause null errors // Create a safe handle that won't cause null errors

View File

@ -296,13 +296,40 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
console.error('❌ Error applying presence:', error) console.error('❌ Error applying presence:', error)
} }
}, [flushPresenceUpdates]) }, [flushPresenceUpdates])
// Handle presence leave - remove the user's presence record from the store
const handlePresenceLeave = useCallback((sessionId: string) => {
const currentStore = storeRef.current
if (!currentStore) return
try {
// Find and remove the presence record for this session
// Presence IDs are formatted as "instance_presence:{sessionId}"
const presenceId = `instance_presence:${sessionId}`
// Check if this record exists before trying to remove it
const allRecords = currentStore.allRecords()
const presenceRecord = allRecords.find((r: any) =>
r.id === presenceId ||
r.id?.includes(sessionId)
)
if (presenceRecord) {
console.log('👋 Removing presence record for session:', sessionId, presenceRecord.id)
currentStore.remove([presenceRecord.id])
}
} catch (error) {
console.error('Error removing presence on leave:', error)
}
}, [])
const { repo, adapter, storageAdapter } = useMemo(() => { const { repo, adapter, storageAdapter } = useMemo(() => {
const adapter = new CloudflareNetworkAdapter( const adapter = new CloudflareNetworkAdapter(
workerUrl, workerUrl,
roomId, roomId,
applyJsonSyncData, applyJsonSyncData,
applyPresenceUpdate applyPresenceUpdate,
handlePresenceLeave
) )
// Store adapter ref for use in callbacks // Store adapter ref for use in callbacks
@ -325,7 +352,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
}) })
return { repo, adapter, storageAdapter } return { repo, adapter, storageAdapter }
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate]) }, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate, handlePresenceLeave])
// Subscribe to connection state changes // Subscribe to connection state changes
useEffect(() => { useEffect(() => {
@ -653,20 +680,26 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
} }
// Get user metadata for presence // Get user metadata for presence
// Color is generated from the username (name) for consistency across sessions,
// not from the unique session ID (userId) which changes per tab/session
const userMetadata: { userId: string; name: string; color: string } = (() => { const userMetadata: { userId: string; name: string; color: string } = (() => {
if (user && 'userId' in user) { if (user && 'userId' in user) {
const uid = (user as { userId: string; name: string; color?: string }).userId const uid = (user as { userId: string; name: string; color?: string }).userId
const name = (user as { userId: string; name: string; color?: string }).name
return { return {
userId: uid, userId: uid,
name: (user as { userId: string; name: string; color?: string }).name, name: name,
color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(uid) // Use name for color (consistent across sessions), fall back to uid if no name
color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(name || uid)
} }
} }
const uid = user?.id || 'anonymous' const uid = user?.id || 'anonymous'
const name = user?.name || 'Anonymous'
return { return {
userId: uid, userId: uid,
name: user?.name || 'Anonymous', name: name,
color: generateUserColor(uid) // Use name for color (consistent across sessions), fall back to uid if no name
color: generateUserColor(name !== 'Anonymous' ? name : uid)
} }
})() })()
@ -674,7 +707,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const storeWithStatus = useAutomergeStoreV2({ const storeWithStatus = useAutomergeStoreV2({
handle: handle || null as any, handle: handle || null as any,
userId: userMetadata.userId, userId: userMetadata.userId,
adapter: adapter // Pass adapter for JSON sync broadcasting adapter: adapter, // Pass adapter for JSON sync broadcasting
isNetworkOnline // Pass network state for offline support
}) })
// Update store ref when store is available // Update store ref when store is available

View File

@ -40,8 +40,8 @@ export function ConnectionStatusIndicator({
color: '#8b5cf6', // Purple - calm, not alarming color: '#8b5cf6', // Purple - calm, not alarming
icon: '🍄', icon: '🍄',
pulse: false, pulse: false,
description: 'Your data is safe and encrypted locally', description: 'Viewing locally saved canvas',
detailedMessage: `Your canvas is stored securely in your browser using encrypted local storage. All changes are preserved with your personal encryption key. When you reconnect, your work will automatically sync with the shared canvas — no data will be lost.`, detailedMessage: `You're viewing your locally cached canvas. All your previous work is safely stored in your browser. Any changes you make will be saved locally and automatically synced when you reconnect — no data will be lost.`,
} }
} }

View File

@ -45,6 +45,9 @@ import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
import { ImageGenTool } from "@/tools/ImageGenTool" import { ImageGenTool } from "@/tools/ImageGenTool"
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil" import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
import { VideoGenTool } from "@/tools/VideoGenTool" import { VideoGenTool } from "@/tools/VideoGenTool"
import { DrawfastShape } from "@/shapes/DrawfastShapeUtil"
import { DrawfastTool } from "@/tools/DrawfastTool"
import { LiveImageProvider } from "@/hooks/useLiveImage"
import { MultmuxTool } from "@/tools/MultmuxTool" import { MultmuxTool } from "@/tools/MultmuxTool"
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil" import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility // MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
@ -152,6 +155,7 @@ const customShapeUtils = [
FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser
ImageGenShape, ImageGenShape,
VideoGenShape, VideoGenShape,
DrawfastShape,
MultmuxShape, MultmuxShape,
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
@ -173,6 +177,7 @@ const customTools = [
FathomMeetingsTool, FathomMeetingsTool,
ImageGenTool, ImageGenTool,
VideoGenTool, VideoGenTool,
DrawfastTool,
MultmuxTool, MultmuxTool,
PrivateWorkspaceTool, PrivateWorkspaceTool,
GoogleItemTool, GoogleItemTool,
@ -383,12 +388,16 @@ export function Board() {
} }
// Handler for successful authentication from banner // Handler for successful authentication from banner
// NOTE: We don't call fetchBoardPermission here because:
// 1. This callback captures the OLD fetchBoardPermission from before re-render
// 2. The useEffect watching session.authed already handles re-fetching
// 3. That useEffect will run AFTER React re-renders with the new (cache-cleared) callback
const handleAuthenticated = () => { const handleAuthenticated = () => {
setShowEditPrompt(false) setShowEditPrompt(false)
// Re-fetch permission after authentication // Force permission state reset - the useEffect will fetch fresh permissions
fetchBoardPermission(roomId).then(perm => { setPermission(null)
setPermission(perm) setPermissionLoading(true)
}) console.log('🔐 handleAuthenticated: Cleared permission state, useEffect will fetch fresh')
} }
// Store roomId in localStorage for VideoChatShapeUtil to access // Store roomId in localStorage for VideoChatShapeUtil to access
@ -444,10 +453,13 @@ export function Board() {
} }
// Set up user preferences for TLDraw collaboration // Set up user preferences for TLDraw collaboration
// Color is based on session.username (CryptID) for consistency across sessions
// uniqueUserId is used for tldraw's presence system (allows multiple tabs)
const [userPreferences, setUserPreferences] = useState<TLUserPreferences>(() => ({ const [userPreferences, setUserPreferences] = useState<TLUserPreferences>(() => ({
id: uniqueUserId || 'anonymous', id: uniqueUserId || 'anonymous',
name: session.username || 'Anonymous', name: session.username || 'Anonymous',
color: uniqueUserId ? generateUserColor(uniqueUserId) : '#000000', // Use session.username for color (not uniqueUserId) so color is consistent across all browser sessions
color: session.username ? generateUserColor(session.username) : (uniqueUserId ? generateUserColor(uniqueUserId) : '#000000'),
colorScheme: getColorScheme(), colorScheme: getColorScheme(),
})) }))
@ -457,7 +469,8 @@ export function Board() {
setUserPreferences({ setUserPreferences({
id: uniqueUserId, id: uniqueUserId,
name: session.username || 'Anonymous', name: session.username || 'Anonymous',
color: generateUserColor(uniqueUserId), // Use session.username for color consistency across sessions
color: session.username ? generateUserColor(session.username) : generateUserColor(uniqueUserId),
colorScheme: getColorScheme(), colorScheme: getColorScheme(),
}) })
} }
@ -485,6 +498,7 @@ export function Board() {
return () => observer.disconnect() return () => observer.disconnect()
}, []) }, [])
// Create the user object for TLDraw // Create the user object for TLDraw
const user = useTldrawUser({ userPreferences, setUserPreferences }) const user = useTldrawUser({ userPreferences, setUserPreferences })
@ -918,12 +932,43 @@ export function Board() {
} }
}, [editor, store.store, store.status]) }, [editor, store.store, store.status])
// Update presence when session changes // Update presence when session changes and clean up stale presences
useEffect(() => { useEffect(() => {
if (!editor || !session.authed || !session.username) return if (!editor) return
// The presence should automatically update through the useAutomergeSync configuration const cleanupStalePresences = () => {
// when the session changes, but we can also try to force an update try {
const allRecords = editor.store.allRecords()
const presenceRecords = allRecords.filter((r: any) =>
r.typeName === 'instance_presence' ||
r.id?.startsWith('instance_presence:')
)
if (presenceRecords.length > 0) {
// Filter out stale presences (older than 30 seconds)
const now = Date.now()
const staleThreshold = 30 * 1000 // 30 seconds
const stalePresences = presenceRecords.filter((r: any) =>
r.lastActivityTimestamp && (now - r.lastActivityTimestamp > staleThreshold)
)
if (stalePresences.length > 0) {
console.log(`🧹 Cleaning up ${stalePresences.length} stale presence record(s)`)
editor.store.remove(stalePresences.map((r: any) => r.id))
}
}
} catch (error) {
console.error('Error cleaning up stale presences:', error)
}
}
// Clean up immediately on auth change
cleanupStalePresences()
// Also run periodic cleanup every 15 seconds
const cleanupInterval = setInterval(cleanupStalePresences, 15000)
return () => clearInterval(cleanupInterval)
}, [editor, session.authed, session.username]) }, [editor, session.authed, session.username])
// Update TLDraw user preferences when editor is available and user is authenticated // Update TLDraw user preferences when editor is available and user is authenticated
@ -1140,16 +1185,20 @@ export function Board() {
// Tldraw will automatically render shapes as they're added via patches (like in dev) // Tldraw will automatically render shapes as they're added via patches (like in dev)
const hasStore = !!store.store const hasStore = !!store.store
const isSynced = store.status === 'synced-remote' const isSynced = store.status === 'synced-remote'
// Render as soon as store is synced - shapes will load via patches // OFFLINE SUPPORT: Also render when we have local data but no network
// This allows users to view their board even when offline
const isOfflineWithLocalData = !isNetworkOnline && hasStore && store.status !== 'error'
// Render as soon as store is synced OR we're offline with local data
// This matches dev behavior where Tldraw mounts first, then shapes load // This matches dev behavior where Tldraw mounts first, then shapes load
const shouldRender = hasStore && isSynced const shouldRender = hasStore && (isSynced || isOfflineWithLocalData)
if (!shouldRender) { if (!shouldRender) {
return ( return (
<AutomergeHandleProvider handle={automergeHandle}> <AutomergeHandleProvider handle={automergeHandle}>
<div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}> <div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div>Loading canvas...</div> <div>{!isNetworkOnline ? 'Loading offline data...' : 'Loading canvas...'}</div>
</div> </div>
</AutomergeHandleProvider> </AutomergeHandleProvider>
) )
@ -1158,6 +1207,7 @@ export function Board() {
return ( return (
<AutomergeHandleProvider handle={automergeHandle}> <AutomergeHandleProvider handle={automergeHandle}>
<ConnectionProvider connectionState={connectionState} isNetworkOnline={isNetworkOnline}> <ConnectionProvider connectionState={connectionState} isNetworkOnline={isNetworkOnline}>
<LiveImageProvider>
<div style={{ position: "fixed", inset: 0 }}> <div style={{ position: "fixed", inset: 0 }}>
<Tldraw <Tldraw
key={`tldraw-${authChangeCount}-${session.authed ? 'authed' : 'anon'}-${session.username || 'guest'}`} key={`tldraw-${authChangeCount}-${session.authed ? 'authed' : 'anon'}-${session.username || 'guest'}`}
@ -1299,6 +1349,7 @@ export function Board() {
/> />
)} )}
</div> </div>
</LiveImageProvider>
</ConnectionProvider> </ConnectionProvider>
</AutomergeHandleProvider> </AutomergeHandleProvider>
) )