diff --git a/CHANGELOG.md b/CHANGELOG.md
index 79f8ecd9..b837687c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,26 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.5.10] - 2026-07-01
+
+The 0.5.10 patch is the second of three releases remediating the second swarm audit — the frontend slice — fixing auto-save data-loss and stale-cache defects in the annotation UI ([#196](https://github.com/parafovea/fovea/pull/196)). Several were gaps in the first audit's own frontend fixes, which closed one instance of a defect class without closing every instance. Nothing is breaking.
+
+### Fixed
+
+#### Auto-save no longer drops edits
+
+- A forced save issued while another save was already in flight was silently dropped: the dialog's Done/Escape/backdrop flush, a keyframe override, and the session-expiry emergency flush could each return as if the write succeeded while persisting nothing. A forced save is now parked and drained once the in-flight save settles, and it reports whether it actually wrote (`annotation-tool/src/hooks/data/useAutoSave.ts`).
+- Switching the persona in an open video-summary editor dropped the unsaved edits made under the previous persona — and, in the render window where the id had already changed but the loaded content had not, could persist one persona's text against another. The dialog now flushes the editor before switching and keys the editor by persona so it remounts cleanly for the new one (`annotation-tool/src/components/video/VideoSummaryDialog.tsx`, `VideoSummaryEditor.tsx`).
+- The session-expiry emergency flush counted a no-op forced save (blocked by an in-flight save, skipped by change detection, or superseded) as a successful save, over-reporting preservation at the moment it matters most. It now counts only editors that actually persisted (`annotation-tool/src/hooks/data/autoSaveRegistry.ts`, `useAutoSave.ts`).
+- Opening the editor fired a redundant auto-save of the just-loaded content, because the change baseline started empty and the initial sync looked like an edit. The editor now seeds the baseline to the adopted server content, so the first debounce tick sees no change (`annotation-tool/src/hooks/data/useAutoSave.ts`, `VideoSummaryEditor.tsx`).
+
+#### Stale caches refreshed after mutations
+
+- Deleting an ontology type from the persona editor left open annotation views and world panels showing annotations and per-persona type-assignments the server had already removed, because the delete hooks wired to the buttons never invalidated the annotation and world caches (only their unused twins did). Those four hooks now invalidate both (`annotation-tool/src/store/queries/usePersonas.ts`).
+- A world collection or relation the user had just deleted could reappear after a quick follow-up add or edit: the add path built its whole-array PUT from a cache that still held the deleted object, and the merge-by-id server re-created it. The delete hooks now strip the object from the cache before any following write (`annotation-tool/src/store/queries/useWorld.ts`).
+- Creating or deleting a project-scoped persona did not refresh the project's persona list, so the project detail page showed a stale roster within its two-minute cache window. Both mutations now invalidate the project-personas cache (`annotation-tool/src/store/queries/usePersonas.ts`).
+- Accepting several AI ontology suggestions at once, or importing several Wikidata items quickly, left the ontology editor showing only a subset of the persisted types: the concurrent optimistic writes read the same base snapshot and the last one won. The add/update hooks now invalidate the per-persona ontology so the authoritative server state (merged by id) is refetched once the concurrent writes settle (`annotation-tool/src/store/queries/usePersonas.ts`).
+
## [0.5.9] - 2026-06-30
The 0.5.9 patch is the first of three releases remediating a second swarm audit of the shipped 0.5.x line — the backend slice — fixing authorization, lost-update concurrency, idempotency, and data-fidelity defects in the Fastify server ([#194](https://github.com/parafovea/fovea/pull/194)). Roughly a third of the findings were gaps in the first audit's own fixes: a fix addressed specific instances of a defect class without closing every instance. Nothing is breaking.
diff --git a/annotation-tool/package.json b/annotation-tool/package.json
index cb4868bf..ea662a89 100644
--- a/annotation-tool/package.json
+++ b/annotation-tool/package.json
@@ -1,7 +1,7 @@
{
"name": "@fovea/annotation-tool",
"private": true,
- "version": "0.5.9",
+ "version": "0.5.10",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/annotation-tool/src/components/video/VideoSummaryDialog.tsx b/annotation-tool/src/components/video/VideoSummaryDialog.tsx
index 1b9c8f7b..67b7f1e9 100644
--- a/annotation-tool/src/components/video/VideoSummaryDialog.tsx
+++ b/annotation-tool/src/components/video/VideoSummaryDialog.tsx
@@ -66,6 +66,19 @@ export default function VideoSummaryDialog({
onClose()
}
+ // Switching persona must first flush any unsaved edits made under the current
+ // persona — otherwise a mid-debounce edit is dropped, and worse, a debounce
+ // tick in the render window where the id has already changed could persist the
+ // previous persona's text against the new persona. Flush when dirty, then
+ // switch; the editor is also keyed by persona below, so it remounts cleanly
+ // for the new one rather than carrying the previous persona's local state.
+ const handlePersonaChange = async (value: string | null) => {
+ if (editorRef.current) {
+ await editorRef.current.flushIfDirty()
+ }
+ setSelectedPersonaId(value || null)
+ }
+
// Update selected persona when initial persona changes (e.g., when dialog
// opens). On a scrub-capture resume, prefer the draft's persona so the
// in-progress claim re-opens under the persona it was authored with.
@@ -87,7 +100,7 @@ export default function VideoSummaryDialog({
Select Persona
setSelectedPersonaId(value || null)}
+ onValueChange={(value) => { void handlePersonaChange(value) }}
>
{/* Explicit child override: the base-ui Select reads its
@@ -131,6 +144,12 @@ export default function VideoSummaryDialog({
{selectedPersonaId && videoId && (
Promise
+ /** Persist only if there are unsaved edits (used before a persona switch). */
+ flushIfDirty: () => Promise
}
const VideoSummaryEditor = forwardRef(function VideoSummaryEditor({
@@ -133,25 +136,28 @@ const VideoSummaryEditor = forwardRef {
+ // Autosave callback - memoized to prevent useAutoSave from re-triggering. The
+ // summary and comment are carried together as the autosave `data` so change
+ // detection and baseline seeding cover both fields from one snapshot (no
+ // getComparisonSnapshot closure over the comment, which would lag a render).
+ const handleAutoSave = useCallback(async ({ summary, comment }: { summary: GlossItem[]; comment: string }) => {
if (!currentSummary) {
// Create new summary - only required fields
- await saveSummaryMutation.mutateAsync({ videoId, personaId, summary, comment: localComment.trim() || null })
+ await saveSummaryMutation.mutateAsync({ videoId, personaId, summary, comment: comment.trim() || null })
} else {
// Update existing summary - spread only defined optional fields
await saveSummaryMutation.mutateAsync({
videoId: currentSummary.videoId,
personaId: currentSummary.personaId,
summary,
- comment: localComment.trim() || null,
+ comment: comment.trim() || null,
...(currentSummary.visualAnalysis && { visualAnalysis: currentSummary.visualAnalysis }),
...(currentSummary.audioTranscript && { audioTranscript: currentSummary.audioTranscript }),
...(currentSummary.keyFrames && { keyFrames: currentSummary.keyFrames }),
...(currentSummary.confidence != null && { confidence: currentSummary.confidence }),
})
}
- }, [videoId, personaId, currentSummary, saveSummaryMutation, localComment])
+ }, [videoId, personaId, currentSummary, saveSummaryMutation])
// Use autosave hook for summary persistence
// Note: isEnabled doesn't need the ref check - the ref is only for preventing
@@ -161,14 +167,14 @@ const VideoSummaryEditor = forwardRef ({ summary, comment: localComment }),
+ // Summary and comment travel together so a comment-only edit trips the
+ // debounce (it lives in the compared snapshot) and the baseline can be
+ // seeded from one value on adoption. handleAutoSave receives both.
+ data: { summary: localSummary, comment: localComment },
// Autosave is enabled as soon as videoId + personaId resolve. The
// previous gate (&& !!summaryId) meant the dialog could only
// autosave AFTER a row already existed for this video/persona —
@@ -188,10 +194,15 @@ const VideoSummaryEditor = forwardRef ({
- forceSave,
- }), [forceSave])
+ forceSave: async () => { await forceSave() },
+ flushIfDirty: async () => { if (pendingChanges) await forceSave() },
+ }), [forceSave, pendingChanges])
// Track if we've already tried to create an empty summary for this video/persona
const creatingEmptySummaryRef = useRef(null)
@@ -249,11 +260,15 @@ const VideoSummaryEditor = forwardRef {
// Reset so we can try again if needed
@@ -294,7 +312,7 @@ const VideoSummaryEditor = forwardRef {
diff --git a/annotation-tool/src/hooks/data/autoSaveRegistry.test.ts b/annotation-tool/src/hooks/data/autoSaveRegistry.test.ts
new file mode 100644
index 00000000..629aad66
--- /dev/null
+++ b/annotation-tool/src/hooks/data/autoSaveRegistry.test.ts
@@ -0,0 +1,47 @@
+/**
+ * Unit tests for the auto-save flush registry.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { registerAutoSaveFlush, flushAllAutoSaves } from './autoSaveRegistry'
+
+describe('flushAllAutoSaves', () => {
+ it('counts only editors that actually persisted, not blocked/skipped/superseded no-ops', async () => {
+ // A resolved flush that wrote nothing (blocked by an in-flight save, skipped
+ // by change detection, or superseded by a newer force) must not be tallied
+ // as saved — otherwise the session-expiry flush over-reports preservation.
+ const unregister = [
+ registerAutoSaveFlush(async () => 'saved'),
+ registerAutoSaveFlush(async () => 'saved'),
+ registerAutoSaveFlush(async () => 'skipped'),
+ registerAutoSaveFlush(async () => 'blocked'),
+ registerAutoSaveFlush(async () => 'superseded'),
+ registerAutoSaveFlush(async () => {
+ throw new Error('boom')
+ }),
+ ]
+
+ try {
+ const { saved, errors } = await flushAllAutoSaves()
+ expect(saved).toBe(2)
+ expect(errors).toHaveLength(1)
+ expect(errors[0].message).toBe('boom')
+ } finally {
+ unregister.forEach((u) => u())
+ }
+ })
+
+ it('reports zero saved when every editor no-ops', async () => {
+ const unregister = [
+ registerAutoSaveFlush(async () => 'blocked'),
+ registerAutoSaveFlush(async () => 'skipped'),
+ ]
+ try {
+ const { saved, errors } = await flushAllAutoSaves()
+ expect(saved).toBe(0)
+ expect(errors).toHaveLength(0)
+ } finally {
+ unregister.forEach((u) => u())
+ }
+ })
+})
diff --git a/annotation-tool/src/hooks/data/autoSaveRegistry.ts b/annotation-tool/src/hooks/data/autoSaveRegistry.ts
index d9d7bdaf..70cfda07 100644
--- a/annotation-tool/src/hooks/data/autoSaveRegistry.ts
+++ b/annotation-tool/src/hooks/data/autoSaveRegistry.ts
@@ -8,7 +8,9 @@
* @module
*/
-type FlushFn = () => Promise
+import type { SaveOutcome } from './useAutoSave'
+
+type FlushFn = () => Promise
const flushCallbacks = new Set()
@@ -28,11 +30,13 @@ export function registerAutoSaveFlush(flush: FlushFn): () => void {
/**
* Flush every registered auto-save concurrently. Resolves with the count of
- * callbacks that flushed without throwing and the errors from those that did,
- * so a caller (e.g. emergency save) can report a real outcome rather than a
- * no-op. Never rejects.
+ * editors that ACTUALLY persisted a write and the errors from those that
+ * failed, so a caller (e.g. emergency save) reports a real outcome rather than a
+ * no-op. A forced save that was change-skipped, blocked, or superseded resolves
+ * without throwing but wrote nothing, so it is not counted as saved — otherwise
+ * the session-expiry flush would over-report data preservation. Never rejects.
*
- * @returns the number flushed successfully and any errors encountered
+ * @returns the number that actually persisted and any errors encountered
*/
export async function flushAllAutoSaves(): Promise<{ saved: number; errors: Error[] }> {
const callbacks = Array.from(flushCallbacks)
@@ -40,5 +44,8 @@ export async function flushAllAutoSaves(): Promise<{ saved: number; errors: Erro
const errors = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map((r) => (r.reason instanceof Error ? r.reason : new Error(String(r.reason))))
- return { saved: results.length - errors.length, errors }
+ const saved = results.filter(
+ (r): r is PromiseFulfilledResult => r.status === 'fulfilled' && r.value === 'saved'
+ ).length
+ return { saved, errors }
}
diff --git a/annotation-tool/src/hooks/data/useAutoSave.test.ts b/annotation-tool/src/hooks/data/useAutoSave.test.ts
index 56ca9b29..10ffbea9 100644
--- a/annotation-tool/src/hooks/data/useAutoSave.test.ts
+++ b/annotation-tool/src/hooks/data/useAutoSave.test.ts
@@ -723,4 +723,91 @@ describe('useAutoSave', () => {
}
})
})
+
+ describe('in-flight force queue, outcome, and baseline seeding', () => {
+ it('queues a forceSave issued while a save is in flight and drains it (never dropped)', async () => {
+ // Hold the first save open so a second forceSave lands while it runs.
+ let releaseFirst!: () => void
+ const first = new Promise((resolve) => {
+ releaseFirst = resolve
+ })
+ const onSave = vi
+ .fn<(data: { v: string }) => Promise>()
+ .mockImplementationOnce(() => first)
+ .mockResolvedValue(undefined)
+
+ const { result } = renderHook(() =>
+ useAutoSave({ data: { v: 'render' }, isEnabled: true, onSave, entityType: 'annotation' })
+ )
+
+ let firstOutcome!: Promise
+ let secondOutcome!: Promise
+ act(() => {
+ firstOutcome = result.current.forceSave({ v: 'first' })
+ })
+ // While the first is still in flight, force again with new data.
+ act(() => {
+ secondOutcome = result.current.forceSave({ v: 'second' })
+ })
+
+ await act(async () => {
+ releaseFirst()
+ await firstOutcome
+ await secondOutcome
+ })
+
+ // Both writes land — the second was queued, not silently dropped.
+ expect(onSave).toHaveBeenCalledTimes(2)
+ expect(onSave).toHaveBeenNthCalledWith(1, { v: 'first' })
+ expect(onSave).toHaveBeenNthCalledWith(2, { v: 'second' })
+ })
+
+ it('forceSave resolves to "saved" on a real write', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined)
+ const { result } = renderHook(() =>
+ useAutoSave({ data: { v: 'x' }, isEnabled: true, onSave, entityType: 'annotation' })
+ )
+ let outcome: unknown
+ await act(async () => {
+ outcome = await result.current.forceSave()
+ })
+ expect(outcome).toBe('saved')
+ })
+
+ it('markSaved seeds the baseline so a debounce tick with the same data does not save', async () => {
+ vi.useFakeTimers()
+ try {
+ const onSave = vi.fn().mockResolvedValue(undefined)
+ const { result } = renderHook(() =>
+ useAutoSave({ data: { v: 'loaded' }, isEnabled: true, onSave, debounceMs: 100, entityType: 'summary' })
+ )
+ // The editor adopts server content and seeds the baseline to it.
+ act(() => {
+ result.current.markSaved({ v: 'loaded' })
+ })
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(200)
+ })
+ expect(onSave).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('without markSaved, the initial debounce tick saves the just-loaded data (the spurious save markSaved fixes)', async () => {
+ vi.useFakeTimers()
+ try {
+ const onSave = vi.fn().mockResolvedValue(undefined)
+ renderHook(() =>
+ useAutoSave({ data: { v: 'loaded' }, isEnabled: true, onSave, debounceMs: 100, entityType: 'summary' })
+ )
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(200)
+ })
+ expect(onSave).toHaveBeenCalledTimes(1)
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+ })
})
diff --git a/annotation-tool/src/hooks/data/useAutoSave.ts b/annotation-tool/src/hooks/data/useAutoSave.ts
index d0bfa535..fb0a5252 100644
--- a/annotation-tool/src/hooks/data/useAutoSave.ts
+++ b/annotation-tool/src/hooks/data/useAutoSave.ts
@@ -17,6 +17,18 @@ import { registerAutoSaveFlush } from './autoSaveRegistry'
*/
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'retrying'
+/**
+ * Outcome of a single save attempt, returned by `forceSave` so callers (the
+ * emergency-flush registry, a dialog closing) can tell a real write from a
+ * no-op:
+ * - `saved`: the data was persisted.
+ * - `skipped`: change detection found nothing to write (non-forced only).
+ * - `blocked`: a non-forced save no-oped because one was already in flight.
+ * - `superseded`: a queued forced save was replaced by a newer forced save.
+ * - `error`: the save failed after exhausting retries.
+ */
+export type SaveOutcome = 'saved' | 'skipped' | 'blocked' | 'superseded' | 'error'
+
/**
* Entity types supported by auto-save for observability.
*
@@ -93,8 +105,20 @@ export interface UseAutoSaveReturn {
*
* @param dataOverride - the exact data to persist, used in place of the
* render-time `data`
+ * @returns the outcome of the save (whether it actually persisted)
+ */
+ forceSave: (dataOverride?: T) => Promise
+ /**
+ * Seed the change-detection baseline to `savedData` without writing.
+ *
+ * Call this the moment the editor adopts freshly loaded server content so the
+ * first debounce tick sees no change and does not fire a spurious save of the
+ * just-fetched data. Without it the baseline stays empty and the initial sync
+ * looks like a user edit.
+ *
+ * @param savedData - the data now considered already persisted
*/
- forceSave: (dataOverride?: T) => Promise
+ markSaved: (savedData: T) => void
}
/**
@@ -142,6 +166,11 @@ export function useAutoSave({
const [retryCount, setRetryCount] = useState(0)
const saveInProgressRef = useRef(false)
+ // A forced save requested while another save is in flight is parked here and
+ // drained when the in-flight save settles, so a final edit (dialog close,
+ // keyframe override, emergency flush) is never silently dropped. Its resolver
+ // reports the eventual outcome to the original forceSave caller.
+ const pendingForceRef = useRef<{ dataOverride?: T; resolve: (outcome: SaveOutcome) => void } | null>(null)
const debounceTimerRef = useRef | null>(null)
const periodicTimerRef = useRef | null>(null)
const dataRef = useRef(data)
@@ -181,10 +210,24 @@ export function useAutoSave({
// Core save function with retry logic. `dataOverride`, when provided, is the
// exact data to persist; it is used in place of dataRef so a forced save can
- // carry an edit that has not yet propagated back into `data`.
+ // carry an edit that has not yet propagated back into `data`. Returns the
+ // outcome so a forced save's caller can tell a real write from a no-op.
const performSave = useCallback(
- async (attempt = 0, force = false, dataOverride?: T): Promise => {
- if (saveInProgressRef.current) return
+ async (force = false, dataOverride?: T): Promise => {
+ // A save is already running. A non-forced tick simply skips (its data will
+ // be picked up by the debounce/periodic loop). A forced save must NOT be
+ // dropped: park it, and it is drained in the in-flight save's `finally`.
+ // The returned promise resolves with the eventual outcome of that drained
+ // write, so `forceSave` reports real persistence, not a no-op.
+ if (saveInProgressRef.current) {
+ if (!force) return 'blocked'
+ return new Promise((resolve) => {
+ const previous = pendingForceRef.current
+ pendingForceRef.current = { dataOverride, resolve }
+ // Only the latest forced save is kept; supersede an older parked one.
+ if (previous) previous.resolve('superseded')
+ })
+ }
const currentData = dataOverride !== undefined ? dataOverride : dataRef.current
const serialized = serialize(currentData)
@@ -197,85 +240,91 @@ export function useAutoSave({
// bypassing the guard here cannot re-introduce the auto-save loop.
if (!force && serialized === lastSavedDataRef.current) {
setPendingChanges(false)
- return
+ return 'skipped'
}
saveInProgressRef.current = true
- setSaveStatus(attempt > 0 ? 'retrying' : 'saving')
- setRetryCount(attempt)
-
- // When a retry is scheduled below, the in-progress guard must stay held
- // through the backoff delay so a debounce/periodic tick cannot start a
- // second save concurrently with the pending retry (duplicate writes /
- // last-writer-wins). The retry timeout releases and re-runs.
- let retryScheduled = false
+ let outcome: SaveOutcome = 'saved'
try {
- await withSpan(
- `${entityType}-autosave`,
- { entityId: entityId || 'unknown', entityType },
- async (span) => {
- await onSave(currentData)
- span.setAttribute('save_success', true)
- span.setAttribute('retry_count', attempt)
+ // Retry with exponential backoff (1s, 2s, 4s) INSIDE this invocation, so
+ // the in-progress guard stays held across the whole sequence — a
+ // debounce/periodic tick cannot start a second save concurrently with a
+ // pending retry (duplicate writes / last-writer-wins) — and the final
+ // outcome is known when this promise resolves.
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
+ setSaveStatus(attempt > 0 ? 'retrying' : 'saving')
+ setRetryCount(attempt)
+ try {
+ await withSpan(
+ `${entityType}-autosave`,
+ { entityId: entityId || 'unknown', entityType },
+ async (span) => {
+ await onSave(currentData)
+ span.setAttribute('save_success', true)
+ span.setAttribute('retry_count', attempt)
+ }
+ )
+
+ lastSavedDataRef.current = serialized
+ setLastSavedAt(new Date())
+ setSaveStatus('saved')
+ setPendingChanges(false)
+ setErrorMessage(null)
+ setRetryCount(0)
+ outcome = 'saved'
+ break
+ } catch (error) {
+ const err = error as Error
+
+ // Auth errors (401) are not retried — the session is invalid.
+ 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.')
+ logWarning(`${entityType} save failed due to auth error`, {
+ entityId,
+ error: err.message,
+ })
+ outcome = 'error'
+ break
+ }
+
+ if (attempt < maxRetries - 1) {
+ logWarning(`${entityType} save failed, retrying`, {
+ entityId,
+ retryCount: attempt + 1,
+ error: err.message,
+ })
+ await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000))
+ continue
+ }
+
+ setSaveStatus('error')
+ setErrorMessage(err.message)
+ logCritical(err, {
+ component: `useAutoSave:${entityType}`,
+ entityId,
+ })
+ outcome = 'error'
}
- )
-
- lastSavedDataRef.current = serialized
- setLastSavedAt(new Date())
- setSaveStatus('saved')
- setPendingChanges(false)
- setErrorMessage(null)
- setRetryCount(0)
- } catch (error) {
- const err = error as Error
-
- // Check if this is an auth error (401) - don't retry auth errors
- const isAuthError =
- err.message.includes('401') ||
- err.message.includes('Unauthorized') ||
- err.message.includes('Session expired')
-
- if (isAuthError) {
- // Auth errors should not be retried - the session is invalid
- setSaveStatus('error')
- setErrorMessage('Session expired. Please log in again.')
- logWarning(`${entityType} save failed due to auth error`, {
- entityId,
- error: err.message,
- })
- // Don't log as critical - this is an expected auth flow issue
- } else if (attempt < maxRetries - 1) {
- // Exponential backoff: 1s, 2s, 4s
- const delay = Math.pow(2, attempt) * 1000
- retryScheduled = true
- logWarning(`${entityType} save failed, retrying`, {
- entityId,
- retryCount: attempt + 1,
- error: err.message,
- })
-
- setTimeout(() => {
- // Release and re-run synchronously: performSave re-acquires the guard
- // before its first await, so no concurrent save can slip into the gap.
- saveInProgressRef.current = false
- performSave(attempt + 1, force, dataOverride)
- }, delay)
- } else {
- setSaveStatus('error')
- setErrorMessage(err.message)
- logCritical(err, {
- component: `useAutoSave:${entityType}`,
- entityId,
- })
}
} finally {
- // Release the guard on a terminal outcome (success or final failure),
- // but NOT when a retry is pending — the retry timeout owns the release.
- if (!retryScheduled) {
- saveInProgressRef.current = false
+ saveInProgressRef.current = false
+ // Drain a forced save parked while this one ran, carrying its override,
+ // and resolve its caller's promise with the real outcome.
+ const queued = pendingForceRef.current
+ if (queued) {
+ pendingForceRef.current = null
+ performSave(true, queued.dataOverride).then(queued.resolve, () => queued.resolve('error'))
}
}
+
+ return outcome
},
[onSave, entityType, entityId, maxRetries, serialize]
)
@@ -297,15 +346,26 @@ export function useAutoSave({
performSaveRef.current = performSave
}, [performSave])
- // Force save function
- const forceSave = useCallback(async (dataOverride?: T) => {
+ // Force save function. Returns the outcome so callers (dialog close, keyframe
+ // override, emergency flush) can tell a real write from a no-op — and, via the
+ // in-flight force queue in performSave, a forced save is never silently
+ // dropped when another save is already running.
+ const forceSave = useCallback(async (dataOverride?: T): Promise => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = null
}
- await performSaveRef.current(0, true, dataOverride)
+ return performSaveRef.current(true, dataOverride)
}, [])
+ // Seed the change-detection baseline to already-saved data without writing, so
+ // the first debounce tick after the editor adopts server content sees no
+ // change and does not fire a spurious save.
+ const markSaved = useCallback((savedData: T) => {
+ lastSavedDataRef.current = serialize(savedData)
+ setPendingChanges(false)
+ }, [serialize])
+
// Register this editor's flush so a global handler (emergency save on session
// expiry) can persist its pending edits. forceSave is stable, so this runs
// once; the cleanup deregisters on unmount.
@@ -399,5 +459,6 @@ export function useAutoSave({
errorMessage,
retryCount,
forceSave,
+ markSaved,
}
}
diff --git a/annotation-tool/src/store/queries/usePersonas.ts b/annotation-tool/src/store/queries/usePersonas.ts
index c16bb879..95df0421 100644
--- a/annotation-tool/src/store/queries/usePersonas.ts
+++ b/annotation-tool/src/store/queries/usePersonas.ts
@@ -21,6 +21,7 @@ import { generateId } from '@utils/uuid'
import { sharingKeys } from './useSharing'
import { annotationKeys } from './useAnnotations'
import { worldKeys } from './useWorld'
+import { projectKeys } from './useProjects'
/** Query keys for personas */
export const personaKeys = {
@@ -276,6 +277,11 @@ export function useCreatePersona() {
queryClient.setQueryData(personaKeys.list(), (old = []) => [...old, persona])
// Cache the ontology
queryClient.setQueryData(personaKeys.ontology(persona.id), ontology)
+ // The server persisted a persona->project link; refresh that project's
+ // persona list so ProjectDetailPage shows the new persona.
+ if (variables.projectId) {
+ queryClient.invalidateQueries({ queryKey: projectKeys.personas(variables.projectId) })
+ }
// If the create also issued shares, the Sent Shares panel is now stale.
if (variables.shareWith && variables.shareWith.length > 0) {
queryClient.invalidateQueries({ queryKey: sharingKeys.sent() })
@@ -348,6 +354,11 @@ export function useDeletePersona() {
// refresh those caches so the UI stops showing now-deleted entries.
queryClient.invalidateQueries({ queryKey: annotationKeys.all })
queryClient.invalidateQueries({ queryKey: worldKeys.all })
+ // The persona may belong to one or more projects; the delete response does
+ // not carry the project id, so invalidate every project's persona list
+ // (projectKeys.personas builds [...all, 'personas', id], so this prefix
+ // matches them all).
+ queryClient.invalidateQueries({ queryKey: [...projectKeys.all, 'personas'] })
},
})
}
@@ -471,6 +482,10 @@ export function useAddEntityToPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The optimistic setQueryData above was computed from a snapshot read at
+ // mutationFn entry; concurrent writes read the same snapshot, so refetch
+ // the authoritative server state (which merges by id) once writes settle.
+ queryClient.invalidateQueries({ queryKey: personaKeys.ontology(personaId) })
},
})
}
@@ -512,6 +527,10 @@ export function useUpdateEntityInPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The optimistic setQueryData above was computed from a snapshot read at
+ // mutationFn entry; concurrent writes read the same snapshot, so refetch
+ // the authoritative server state (which merges by id) once writes settle.
+ queryClient.invalidateQueries({ queryKey: personaKeys.ontology(personaId) })
},
})
}
@@ -545,6 +564,11 @@ export function useDeleteEntityFromPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The delete endpoint also removes annotations referencing this type and
+ // per-persona world-object assignments; refresh those caches so the UI
+ // stops showing entries that no longer exist server-side.
+ queryClient.invalidateQueries({ queryKey: annotationKeys.all })
+ queryClient.invalidateQueries({ queryKey: worldKeys.all })
},
})
}
@@ -590,6 +614,10 @@ export function useAddRoleToPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The optimistic setQueryData above was computed from a snapshot read at
+ // mutationFn entry; concurrent writes read the same snapshot, so refetch
+ // the authoritative server state (which merges by id) once writes settle.
+ queryClient.invalidateQueries({ queryKey: personaKeys.ontology(personaId) })
},
})
}
@@ -631,6 +659,10 @@ export function useUpdateRoleInPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The optimistic setQueryData above was computed from a snapshot read at
+ // mutationFn entry; concurrent writes read the same snapshot, so refetch
+ // the authoritative server state (which merges by id) once writes settle.
+ queryClient.invalidateQueries({ queryKey: personaKeys.ontology(personaId) })
},
})
}
@@ -661,6 +693,11 @@ export function useDeleteRoleFromPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The delete endpoint also removes annotations referencing this type and
+ // per-persona world-object assignments; refresh those caches so the UI
+ // stops showing entries that no longer exist server-side.
+ queryClient.invalidateQueries({ queryKey: annotationKeys.all })
+ queryClient.invalidateQueries({ queryKey: worldKeys.all })
},
})
}
@@ -706,6 +743,10 @@ export function useAddEventToPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The optimistic setQueryData above was computed from a snapshot read at
+ // mutationFn entry; concurrent writes read the same snapshot, so refetch
+ // the authoritative server state (which merges by id) once writes settle.
+ queryClient.invalidateQueries({ queryKey: personaKeys.ontology(personaId) })
},
})
}
@@ -747,6 +788,10 @@ export function useUpdateEventInPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The optimistic setQueryData above was computed from a snapshot read at
+ // mutationFn entry; concurrent writes read the same snapshot, so refetch
+ // the authoritative server state (which merges by id) once writes settle.
+ queryClient.invalidateQueries({ queryKey: personaKeys.ontology(personaId) })
},
})
}
@@ -777,6 +822,11 @@ export function useDeleteEventFromPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The delete endpoint also removes annotations referencing this type and
+ // per-persona world-object assignments; refresh those caches so the UI
+ // stops showing entries that no longer exist server-side.
+ queryClient.invalidateQueries({ queryKey: annotationKeys.all })
+ queryClient.invalidateQueries({ queryKey: worldKeys.all })
},
})
}
@@ -822,6 +872,10 @@ export function useAddRelationTypeToPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The optimistic setQueryData above was computed from a snapshot read at
+ // mutationFn entry; concurrent writes read the same snapshot, so refetch
+ // the authoritative server state (which merges by id) once writes settle.
+ queryClient.invalidateQueries({ queryKey: personaKeys.ontology(personaId) })
},
})
}
@@ -863,6 +917,10 @@ export function useUpdateRelationTypeInPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The optimistic setQueryData above was computed from a snapshot read at
+ // mutationFn entry; concurrent writes read the same snapshot, so refetch
+ // the authoritative server state (which merges by id) once writes settle.
+ queryClient.invalidateQueries({ queryKey: personaKeys.ontology(personaId) })
},
})
}
@@ -895,6 +953,11 @@ export function useDeleteRelationTypeFromPersona() {
queryClient.setQueryData(personaKeys.ontology(personaId), ontology)
// Invalidate all-ontologies query so header Save button gets fresh data
queryClient.invalidateQueries({ queryKey: personaKeys.allOntologies() })
+ // The delete endpoint also removes annotations referencing this type and
+ // per-persona world-object assignments; refresh those caches so the UI
+ // stops showing entries that no longer exist server-side.
+ queryClient.invalidateQueries({ queryKey: annotationKeys.all })
+ queryClient.invalidateQueries({ queryKey: worldKeys.all })
},
})
}
diff --git a/annotation-tool/src/store/queries/useWorld.ts b/annotation-tool/src/store/queries/useWorld.ts
index 574a72c5..0c68a1cb 100644
--- a/annotation-tool/src/store/queries/useWorld.ts
+++ b/annotation-tool/src/store/queries/useWorld.ts
@@ -502,7 +502,16 @@ export function useDeleteEntityCollection() {
return useMutation({
mutationFn: (collectionId: string) =>
deleteWorldObject(`/api/world/entity-collections/${collectionId}`, 'entity collection'),
- onSuccess: () => {
+ onSuccess: (_data, collectionId) => {
+ // Strip the deleted collection from the cache before invalidating so an
+ // immediately-following add/update reads a cache that no longer contains
+ // it; otherwise its whole-array PUT would re-send the deleted collection
+ // and the server's merge-by-id would resurrect it.
+ queryClient.setQueryData(worldKeys.state(), (old) =>
+ old
+ ? { ...old, entityCollections: old.entityCollections.filter((c) => c.id !== collectionId) }
+ : old
+ )
queryClient.invalidateQueries({ queryKey: worldKeys.state() })
},
})
@@ -571,7 +580,16 @@ export function useDeleteEventCollection() {
return useMutation({
mutationFn: (collectionId: string) =>
deleteWorldObject(`/api/world/event-collections/${collectionId}`, 'event collection'),
- onSuccess: () => {
+ onSuccess: (_data, collectionId) => {
+ // Strip the deleted collection from the cache before invalidating so an
+ // immediately-following add/update reads a cache that no longer contains
+ // it; otherwise its whole-array PUT would re-send the deleted collection
+ // and the server's merge-by-id would resurrect it.
+ queryClient.setQueryData(worldKeys.state(), (old) =>
+ old
+ ? { ...old, eventCollections: old.eventCollections.filter((c) => c.id !== collectionId) }
+ : old
+ )
queryClient.invalidateQueries({ queryKey: worldKeys.state() })
},
})
@@ -634,7 +652,16 @@ export function useDeleteTimeCollection() {
return useMutation({
mutationFn: (collectionId: string) =>
deleteWorldObject(`/api/world/time-collections/${collectionId}`, 'time collection'),
- onSuccess: () => {
+ onSuccess: (_data, collectionId) => {
+ // Strip the deleted collection from the cache before invalidating so an
+ // immediately-following add/update reads a cache that no longer contains
+ // it; otherwise its whole-array PUT would re-send the deleted collection
+ // and the server's merge-by-id would resurrect it.
+ queryClient.setQueryData(worldKeys.state(), (old) =>
+ old
+ ? { ...old, timeCollections: old.timeCollections.filter((c) => c.id !== collectionId) }
+ : old
+ )
queryClient.invalidateQueries({ queryKey: worldKeys.state() })
},
})
@@ -678,7 +705,16 @@ export function useDeleteRelation() {
return useMutation({
mutationFn: (relationId: string) =>
deleteWorldObject(`/api/world/relations/${relationId}`, 'relation'),
- onSuccess: () => {
+ onSuccess: (_data, relationId) => {
+ // Strip the deleted relation from the cache before invalidating so an
+ // immediately-following add/update reads a cache that no longer contains
+ // it; otherwise its whole-array PUT would re-send the deleted relation and
+ // the server's merge-by-id would resurrect it.
+ queryClient.setQueryData(worldKeys.state(), (old) =>
+ old
+ ? { ...old, relations: old.relations.filter((r) => r.id !== relationId) }
+ : old
+ )
queryClient.invalidateQueries({ queryKey: worldKeys.state() })
},
})
diff --git a/docs/docs/project/changelog.md b/docs/docs/project/changelog.md
index 681583a7..fedbcb60 100644
--- a/docs/docs/project/changelog.md
+++ b/docs/docs/project/changelog.md
@@ -11,6 +11,26 @@ All notable changes to the Fovea project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.5.10] - 2026-07-01
+
+The 0.5.10 patch is the second of three releases remediating the second swarm audit — the frontend slice — fixing auto-save data-loss and stale-cache defects in the annotation UI ([#196](https://github.com/parafovea/fovea/pull/196)). Several were gaps in the first audit's own frontend fixes, which closed one instance of a defect class without closing every instance. Nothing is breaking.
+
+### Fixed
+
+#### Auto-save no longer drops edits
+
+- A forced save issued while another save was already in flight was silently dropped: the dialog's Done/Escape/backdrop flush, a keyframe override, and the session-expiry emergency flush could each return as if the write succeeded while persisting nothing. A forced save is now parked and drained once the in-flight save settles, and it reports whether it actually wrote (`annotation-tool/src/hooks/data/useAutoSave.ts`).
+- Switching the persona in an open video-summary editor dropped the unsaved edits made under the previous persona — and, in the render window where the id had already changed but the loaded content had not, could persist one persona's text against another. The dialog now flushes the editor before switching and keys the editor by persona so it remounts cleanly for the new one (`annotation-tool/src/components/video/VideoSummaryDialog.tsx`, `VideoSummaryEditor.tsx`).
+- The session-expiry emergency flush counted a no-op forced save (blocked by an in-flight save, skipped by change detection, or superseded) as a successful save, over-reporting preservation at the moment it matters most. It now counts only editors that actually persisted (`annotation-tool/src/hooks/data/autoSaveRegistry.ts`, `useAutoSave.ts`).
+- Opening the editor fired a redundant auto-save of the just-loaded content, because the change baseline started empty and the initial sync looked like an edit. The editor now seeds the baseline to the adopted server content, so the first debounce tick sees no change (`annotation-tool/src/hooks/data/useAutoSave.ts`, `VideoSummaryEditor.tsx`).
+
+#### Stale caches refreshed after mutations
+
+- Deleting an ontology type from the persona editor left open annotation views and world panels showing annotations and per-persona type-assignments the server had already removed, because the delete hooks wired to the buttons never invalidated the annotation and world caches (only their unused twins did). Those four hooks now invalidate both (`annotation-tool/src/store/queries/usePersonas.ts`).
+- A world collection or relation the user had just deleted could reappear after a quick follow-up add or edit: the add path built its whole-array PUT from a cache that still held the deleted object, and the merge-by-id server re-created it. The delete hooks now strip the object from the cache before any following write (`annotation-tool/src/store/queries/useWorld.ts`).
+- Creating or deleting a project-scoped persona did not refresh the project's persona list, so the project detail page showed a stale roster within its two-minute cache window. Both mutations now invalidate the project-personas cache (`annotation-tool/src/store/queries/usePersonas.ts`).
+- Accepting several AI ontology suggestions at once, or importing several Wikidata items quickly, left the ontology editor showing only a subset of the persisted types: the concurrent optimistic writes read the same base snapshot and the last one won. The add/update hooks now invalidate the per-persona ontology so the authoritative server state (merged by id) is refetched once the concurrent writes settle (`annotation-tool/src/store/queries/usePersonas.ts`).
+
## [0.5.9] - 2026-06-30
The 0.5.9 patch is the first of three releases remediating a second swarm audit of the shipped 0.5.x line — the backend slice — fixing authorization, lost-update concurrency, idempotency, and data-fidelity defects in the Fastify server ([#194](https://github.com/parafovea/fovea/pull/194)). Roughly a third of the findings were gaps in the first audit's own fixes: a fix addressed specific instances of a defect class without closing every instance. Nothing is breaking.
diff --git a/model-service/pyproject.toml b/model-service/pyproject.toml
index 7de09350..ac013fb8 100644
--- a/model-service/pyproject.toml
+++ b/model-service/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "fovea-model-service"
-version = "0.5.9"
+version = "0.5.10"
description = "Model service for fovea video annotation tool"
requires-python = ">=3.12"
dependencies = [
diff --git a/package.json b/package.json
index f03657e4..18514955 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "fovea",
"private": true,
- "version": "0.5.9",
+ "version": "0.5.10",
"description": "FOVEA - Flexible Ontology Visual Event Analyzer",
"packageManager": "pnpm@10.15.0",
"engines": {
diff --git a/server/package.json b/server/package.json
index a2903043..e9002c1a 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@fovea/server",
- "version": "0.5.9",
+ "version": "0.5.10",
"private": true,
"type": "module",
"scripts": {