Skip to main content

Autosave System

Fovea includes a generic autosave system that automatically persists data changes with debouncing, retry logic, and visual status feedback.

Overview

The autosave system provides:

  • Debounced saves on data changes (default: 1 second)
  • Periodic backup saves (default: every 30 seconds)
  • Retry logic with exponential backoff (1s, 2s, 4s)
  • Automatic save on page visibility change (tab hidden)
  • Automatic save attempt before page unload
  • Visual status indicator component
  • OpenTelemetry tracing for observability

Architecture

Data Change -> Debounce Timer -> Save Attempt -> Success/Retry/Fail
| |
v v
Periodic Timer Status Update
| |
v v
Backup Save UI Indicator

Core Hook: useAutoSave

The useAutoSave hook provides all autosave functionality:

import { useAutoSave } from '@hooks/data'

function AnnotationEditor({ videoId, annotations }) {
const { saveStatus, lastSavedAt, pendingChanges, forceSave } = useAutoSave({
data: annotations,
isEnabled: true,
onSave: async (data) => {
await api.saveAnnotations(videoId, data)
},
entityType: 'annotation',
entityId: videoId,
})

return (
<div>
<SaveStatusIndicator
status={saveStatus}
lastSavedAt={lastSavedAt}
errorMessage={null}
retryCount={0}
onRetry={forceSave}
/>
{/* editor content */}
</div>
)
}

Options

OptionTypeDefaultDescription
dataT(required)Data to auto-save
isEnabledboolean(required)Whether autosave is active
onSave(data: T) => Promise<void>(required)Save function
debounceMsnumber1000Debounce delay in milliseconds
periodicMsnumber30000Periodic backup interval
maxRetriesnumber3Maximum retry attempts
entityTypeAutoSaveEntityType(required)Entity type for tracing
entityIdstring(optional)Entity ID for tracing context

Return Values

PropertyTypeDescription
saveStatusSaveStatusCurrent save status
lastSavedAtDate | nullTimestamp of last successful save
pendingChangesbooleanWhether unsaved changes exist
errorMessagestring | nullError message if save failed
retryCountnumberCurrent retry attempt count
forceSave() => Promise<void>Force immediate save

Save Status States

StatusDescription
idleNo save in progress, no pending changes
savingSave operation in progress
savedSave completed successfully
errorSave failed after all retries
retryingRetrying after a failed save

Entity Types

The entityType parameter is used for observability tracing:

Entity TypeDescription
annotationBounding box annotations
personaPersona definitions
ontologyOntology types (entities, events, roles)
summaryVideo summaries
claimSummary claims
world-objectWorld state objects

Save Triggers

Debounced Save

When data changes, a debounced save triggers after the configured delay:

// Data changes trigger debounced save
useEffect(() => {
if (!isEnabled) return

const serialized = JSON.stringify(data)
if (serialized !== lastSavedDataRef.current) {
setPendingChanges(true)
}

debounceTimerRef.current = setTimeout(() => {
performSave()
}, debounceMs)
}, [data, isEnabled, debounceMs])

Periodic Backup

A periodic backup runs at the configured interval if changes are pending:

periodicTimerRef.current = setInterval(() => {
if (pendingChanges && !saveInProgressRef.current) {
performSave()
}
}, periodicMs)

Visibility Change

When the tab becomes hidden, pending changes are saved:

document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && pendingChanges) {
performSave()
}
})

Page Unload

Before page unload, the system attempts to save and shows a browser warning:

window.addEventListener('beforeunload', (e) => {
if (pendingChanges) {
performSave()
e.preventDefault()
e.returnValue = '' // Shows browser warning
}
})

Retry Logic

Failed saves retry with exponential backoff:

AttemptDelay
1st retry1 second
2nd retry2 seconds
3rd retry4 seconds
After max retriesStatus set to error
// Exponential backoff: 2^attempt * 1000ms
const delay = Math.pow(2, attempt) * 1000
setTimeout(() => performSave(attempt + 1), delay)

Auth Error Handling

Authentication errors (401) do not trigger retries:

const isAuthError =
err.message.includes('401') ||
err.message.includes('Unauthorized') ||
err.message.includes('Session expired')

if (isAuthError) {
setSaveStatus('error')
setErrorMessage('Session expired. Please log in again.')
// No retry
}

SaveStatusIndicator Component

The SaveStatusIndicator component displays visual feedback:

import { SaveStatusIndicator } from '@hooks/data'

<SaveStatusIndicator
status={saveStatus}
lastSavedAt={lastSavedAt}
errorMessage={errorMessage}
retryCount={retryCount}
maxRetries={3}
onRetry={forceSave}
compact={false}
/>

Props

PropTypeDefaultDescription
statusSaveStatus(required)Current save status
lastSavedAtDate | null(required)Last save timestamp
errorMessagestring | null(required)Error message
retryCountnumber(required)Current retry count
maxRetriesnumber3Maximum retries
onRetry() => void(optional)Manual retry callback
compactbooleanfalseUse compact (icon-only) mode

Display States

StatusFull ModeCompact Mode
idleHiddenHidden
savingSpinner + "Saving..."Spinner
savedCheckmark + "Saved at 2:30 PM"Checkmark
errorError icon + "Save failed" + Retry buttonError icon with tooltip
retryingSpinner + "Retrying (2/3)..."Spinner with tooltip

Notification Store

For global notifications, use the notification store:

import { useNotificationStore } from '@store/zustand/notificationStore'

function MyComponent() {
const addNotification = useNotificationStore(state => state.addNotification)

const handleSaveError = () => {
addNotification({
type: 'error',
message: 'Failed to save changes',
autoHide: true, // Auto-dismiss after 5 seconds
})
}
}

Notification Types

TypeUse Case
successOperation completed successfully
errorOperation failed
warningNon-critical issue
infoInformational message

Usage Examples

Annotations Autosave

function AnnotationCanvas({ videoId }) {
const [annotations, setAnnotations] = useState([])

const { saveStatus, lastSavedAt, errorMessage, retryCount, forceSave } = useAutoSave({
data: annotations,
isEnabled: annotations.length > 0,
onSave: async (data) => {
await api.post(`/api/videos/${videoId}/annotations`, data)
},
entityType: 'annotation',
entityId: videoId,
debounceMs: 500, // Fast save for annotations
})

return (
<>
<SaveStatusIndicator
status={saveStatus}
lastSavedAt={lastSavedAt}
errorMessage={errorMessage}
retryCount={retryCount}
onRetry={forceSave}
/>
<Canvas annotations={annotations} onChange={setAnnotations} />
</>
)
}

Persona Autosave

function PersonaEditor({ persona, onUpdate }) {
const [formData, setFormData] = useState(persona)

const { saveStatus } = useAutoSave({
data: formData,
isEnabled: true,
onSave: async (data) => {
await api.put(`/api/personas/${persona.id}`, data)
onUpdate(data)
},
entityType: 'persona',
entityId: persona.id,
debounceMs: 2000, // Slower save for text editing
})

return (
<form>
<input
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
<SaveStatusIndicator status={saveStatus} compact />
</form>
)
}

Ontology Autosave

function OntologyEditor({ personaId }) {
const [ontology, setOntology] = useState(null)

const { saveStatus, forceSave } = useAutoSave({
data: ontology,
isEnabled: ontology !== null,
onSave: async (data) => {
await api.put(`/api/personas/${personaId}/ontology`, data)
},
entityType: 'ontology',
entityId: personaId,
})

// Force save before navigation
useEffect(() => {
return () => {
if (saveStatus === 'saving' || saveStatus === 'retrying') {
forceSave()
}
}
}, [saveStatus, forceSave])

return (/* editor UI */)
}

Telemetry

Autosave operations are traced with OpenTelemetry:

await withSpan(
`${entityType}-autosave`,
{ entityId: entityId || 'unknown', entityType },
async (span) => {
await onSave(currentData)
span.setAttribute('save_success', true)
span.setAttribute('retry_count', attempt)
}
)

Trace attributes:

AttributeDescription
entityIdID of the entity being saved
entityTypeType of entity
save_successWhether save succeeded
retry_countNumber of retries

Testing

Unit Testing useAutoSave

import { renderHook, act, waitFor } from '@testing-library/react'
import { useAutoSave } from '@hooks/data/useAutoSave'

describe('useAutoSave', () => {
it('saves data after debounce', async () => {
const onSave = vi.fn().mockResolvedValue(undefined)

const { result, rerender } = renderHook(
({ data }) => useAutoSave({
data,
isEnabled: true,
onSave,
entityType: 'annotation',
debounceMs: 100,
}),
{ initialProps: { data: { value: 1 } } }
)

rerender({ data: { value: 2 } })

await waitFor(() => {
expect(onSave).toHaveBeenCalledWith({ value: 2 })
})
})

it('retries on failure', async () => {
const onSave = vi.fn()
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(undefined)

const { result } = renderHook(() => useAutoSave({
data: { value: 1 },
isEnabled: true,
onSave,
entityType: 'annotation',
debounceMs: 0,
}))

await waitFor(() => {
expect(onSave).toHaveBeenCalledTimes(2)
expect(result.current.saveStatus).toBe('saved')
})
})
})

Troubleshooting

Data Not Saving

  1. Check isEnabled is true
  2. Verify data object reference changes (use spread operator)
  3. Check network requests in DevTools
  4. Look for errors in console

Too Many Save Requests

Increase debounceMs for frequently changing data:

useAutoSave({
data: frequentlyChangingData,
debounceMs: 2000, // 2 second debounce
// ...
})

Saves Lost on Navigation

Use forceSave in cleanup:

useEffect(() => {
return () => {
forceSave()
}
}, [forceSave])

Or use React Router's useBlocker for navigation confirmation.