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 e2d9382202
commit 4df9e42e4e
4 changed files with 202 additions and 51 deletions

View File

@ -136,10 +136,12 @@ export function useAutomergeStoreV2({
handle,
userId: _userId,
adapter,
isNetworkOnline = true,
}: {
handle: DocHandle<any>
userId: string
adapter?: any
isNetworkOnline?: boolean
}): TLStoreWithStatus {
// useAutomergeStoreV2 initializing
@ -1074,58 +1076,122 @@ export function useAutomergeStoreV2({
try {
await handle.whenReady()
const doc = handle.doc()
// Check if store is already populated from patches
const existingStoreRecords = store.allRecords()
const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape')
// Determine connection status based on network state
const connectionStatus = isNetworkOnline ? "online" : "offline"
if (doc.store) {
const storeKeys = Object.keys(doc.store)
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 (existingStoreShapes.length > 0) {
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
// Shapes should be visible through normal patch application
// If shapes aren't visible, it's likely a different issue that refresh won't fix
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus,
})
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
// The automergeChangeHandler (set up above) should process them automatically
// Just wait a bit for patches to be processed, then set status
if (docShapes > 0 && existingStoreShapes.length === 0) {
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
// The handler is already set up, so it should catch patches from the initial data load
let attempts = 0
const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms)
await new Promise<void>(resolve => {
const checkForPatches = () => {
attempts++
const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape')
if (currentShapes.length > 0) {
console.log(`✅ Patches applied successfully: ${currentShapes.length} shapes loaded via patches`)
// REMOVED: Aggressive shape refresh that was causing coordinate loss
// Shapes loaded via patches should be visible without forced refresh
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus,
})
resolve()
} else if (attempts < maxAttempts) {
@ -1136,35 +1202,35 @@ export function useAutomergeStoreV2({
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(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`)
// Simplified fallback: Just log and continue with empty store
// 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
// that will resolve on next sync
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus,
})
resolve()
}
}
// Start checking immediately since handler is already set up
setTimeout(checkForPatches, 100)
})
return
}
// If doc is empty, just set status
if (docShapes === 0) {
console.log(`📊 Empty document - starting fresh (patch-based loading)`)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus,
})
return
}
@ -1174,7 +1240,7 @@ export function useAutomergeStoreV2({
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus: isNetworkOnline ? "online" : "offline",
})
return
}
@ -1183,17 +1249,17 @@ export function useAutomergeStoreV2({
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
connectionStatus: isNetworkOnline ? "online" : "offline",
})
}
}
initializeStore()
return () => {
unsubs.forEach((unsub) => unsub())
}
}, [handle, store])
}, [handle, store, isNetworkOnline])
/* -------------------- Presence -------------------- */
// 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)
}
}, [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 adapter = new CloudflareNetworkAdapter(
workerUrl,
roomId,
applyJsonSyncData,
applyPresenceUpdate
applyPresenceUpdate,
handlePresenceLeave
)
// Store adapter ref for use in callbacks
@ -325,7 +352,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
})
return { repo, adapter, storageAdapter }
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate])
}, [workerUrl, roomId, applyJsonSyncData, applyPresenceUpdate, handlePresenceLeave])
// Subscribe to connection state changes
useEffect(() => {
@ -653,20 +680,26 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
}
// 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 } = (() => {
if (user && 'userId' in user) {
const uid = (user as { userId: string; name: string; color?: string }).userId
const name = (user as { userId: string; name: string; color?: string }).name
return {
userId: uid,
name: (user as { userId: string; name: string; color?: string }).name,
color: (user as { userId: string; name: string; color?: string }).color || generateUserColor(uid)
name: name,
// 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 name = user?.name || 'Anonymous'
return {
userId: uid,
name: user?.name || 'Anonymous',
color: generateUserColor(uid)
name: name,
// 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({
handle: handle || null as any,
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

View File

@ -40,8 +40,8 @@ export function ConnectionStatusIndicator({
color: '#8b5cf6', // Purple - calm, not alarming
icon: '🍄',
pulse: false,
description: 'Your data is safe and encrypted locally',
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.`,
description: 'Viewing locally saved canvas',
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 { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
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 { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
// 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
ImageGenShape,
VideoGenShape,
DrawfastShape,
MultmuxShape,
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
PrivateWorkspaceShape, // Private zone for Google Export data sovereignty
@ -173,6 +177,7 @@ const customTools = [
FathomMeetingsTool,
ImageGenTool,
VideoGenTool,
DrawfastTool,
MultmuxTool,
PrivateWorkspaceTool,
GoogleItemTool,
@ -383,12 +388,16 @@ export function Board() {
}
// 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 = () => {
setShowEditPrompt(false)
// Re-fetch permission after authentication
fetchBoardPermission(roomId).then(perm => {
setPermission(perm)
})
// Force permission state reset - the useEffect will fetch fresh permissions
setPermission(null)
setPermissionLoading(true)
console.log('🔐 handleAuthenticated: Cleared permission state, useEffect will fetch fresh')
}
// Store roomId in localStorage for VideoChatShapeUtil to access
@ -444,10 +453,13 @@ export function Board() {
}
// 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>(() => ({
id: uniqueUserId || '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(),
}))
@ -457,7 +469,8 @@ export function Board() {
setUserPreferences({
id: uniqueUserId,
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(),
})
}
@ -485,6 +498,7 @@ export function Board() {
return () => observer.disconnect()
}, [])
// Create the user object for TLDraw
const user = useTldrawUser({ userPreferences, setUserPreferences })
@ -918,12 +932,43 @@ export function Board() {
}
}, [editor, store.store, store.status])
// Update presence when session changes
// Update presence when session changes and clean up stale presences
useEffect(() => {
if (!editor || !session.authed || !session.username) return
// The presence should automatically update through the useAutomergeSync configuration
// when the session changes, but we can also try to force an update
if (!editor) return
const cleanupStalePresences = () => {
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])
// 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)
const hasStore = !!store.store
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
const shouldRender = hasStore && isSynced
const shouldRender = hasStore && (isSynced || isOfflineWithLocalData)
if (!shouldRender) {
return (
<AutomergeHandleProvider handle={automergeHandle}>
<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>
</AutomergeHandleProvider>
)
@ -1158,6 +1207,7 @@ export function Board() {
return (
<AutomergeHandleProvider handle={automergeHandle}>
<ConnectionProvider connectionState={connectionState} isNetworkOnline={isNetworkOnline}>
<LiveImageProvider>
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
key={`tldraw-${authChangeCount}-${session.authed ? 'authed' : 'anon'}-${session.username || 'guest'}`}
@ -1299,6 +1349,7 @@ export function Board() {
/>
)}
</div>
</LiveImageProvider>
</ConnectionProvider>
</AutomergeHandleProvider>
)