fix(metadata-panel): apply mutation responses synchronously, no second GET

Single-photo updateMutation only invalidated, so the panel waited for
a follow-up GET /photos/{id} round-trip before showing the new value —
felt as a 200–500 ms lag after every taken_at / rating / notes edit.
Use the PATCH response (already the updated row) to merge into the
per-photo cache and patch every cached timeline list in place.

Bulk taken_at had the same shape: invalidate-only, no optimistic. When
the user dropped back from N selected to one of the modified photos
the panel briefly showed the pre-edit value. Move both bulkSetTakenAt
and bulkSetTakenAtMap into useBulkPhotoMutations alongside the rating/
color/notes pattern, with the same snapshot+patch+rollback primitives.

Tags + bulk tags still invalidate-only — separate change if needed.
This commit is contained in:
Claudio
2026-05-11 21:51:14 +02:00
parent ea08d7e3e8
commit abe5c1ec6b
3 changed files with 105 additions and 28 deletions

View File

@@ -38,6 +38,8 @@ export function RightSidebar() {
bulkRating: bulkRatingMutation,
bulkColor: bulkColorMutation,
bulkNotes: bulkNotesMutation,
bulkTakenAt: bulkTakenAtMutation,
bulkTakenAtMap: bulkTakenAtMapMutation,
invalidatePhotoQueries,
} = useBulkPhotoMutations()
@@ -53,10 +55,11 @@ export function RightSidebar() {
onSuccess: invalidatePhotoQueries,
})
// Shared report-and-invalidate tail for both bulk taken_at mutations.
// They return a partial-apply shape (updated/skipped/errors) because
// EXIF writes can fail per-photo (unsupported format, missing file)
// without wrecking the rest of the batch.
// Toast for bulk taken_at responses. The endpoints return a partial-
// apply shape (updated/skipped/errors) because EXIF writes can fail
// per-photo (unsupported format, missing file) without wrecking the
// rest of the batch. Cache reconciliation is handled inside
// useBulkPhotoMutations; this just surfaces the count to the user.
const reportBulkTakenAt = (
data: {
status: string
@@ -75,25 +78,8 @@ export function RightSidebar() {
} else {
toast.success('Dates updated', detail)
}
invalidatePhotoQueries()
}
const bulkTakenAtMutation = useMutation({
mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) =>
photosApi.bulkSetTakenAt(ids, iso),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', formatApiError(e)),
})
const bulkTakenAtMapMutation = useMutation({
mutationFn: (map: Record<string, string>) =>
photosApi.bulkSetTakenAtMap(map),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', formatApiError(e)),
})
// Bulk tag mutations. Tag mutations also need to invalidate the tags
// query so the FilterBar / sidebar tag counts stay fresh.
const invalidateTagsAndPhotos = () => {
@@ -486,9 +472,16 @@ export function RightSidebar() {
selectedCount={selectedPhotos.length}
collectPhotos={collectSelectedPhotos}
onApplyUniform={(iso) =>
bulkTakenAtMutation.mutate({ ids: selectedPhotos, iso })
bulkTakenAtMutation.mutate(
{ ids: selectedPhotos, iso },
{ onSuccess: reportBulkTakenAt },
)
}
onApplyMap={(map) =>
bulkTakenAtMapMutation.mutate(map, {
onSuccess: reportBulkTakenAt,
})
}
onApplyMap={(map) => bulkTakenAtMapMutation.mutate(map)}
/>
</div>
</div>

View File

@@ -164,8 +164,13 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
placeholderData: keepPreviousData,
})
// Mutation for any patchable field. Invalidates both the photo detail
// cache and the timeline list so the grid reflects the change too.
// Mutation for any patchable field. The PATCH response is the updated
// row, which we merge straight into the per-photo cache so the panel
// paints synchronously — no follow-up GET round-trip before the new
// value appears. Merge (not replace) because the GET endpoint adds a
// `tags` field that PATCH doesn't return; replacing would clobber it.
// Lists and stats are still invalidated so sort order and aggregates
// reconcile with the server.
const updateMutation = useMutation({
mutationFn: (data: {
filename?: string
@@ -176,8 +181,17 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
color_label?: string | null
taken_at?: string
}) => photosApi.update(photoId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
onSuccess: (updated: Partial<PhotoDetails> & { id: string }) => {
queryClient.setQueryData<PhotoDetails>(['photo', photoId], (prev) =>
prev ? { ...prev, ...updated } : (updated as PhotoDetails),
)
queryClient.setQueriesData<PhotoDetails[]>(
{ queryKey: ['photos'] },
(prev) =>
prev
? prev.map((p) => (p.id === updated.id ? { ...p, ...updated } : p))
: prev,
)
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
},

View File

@@ -189,5 +189,75 @@ export function useBulkPhotoMutations() {
onSuccess: invalidatePhotoQueries,
})
return { bulkRating, bulkColor, bulkNotes, invalidatePhotoQueries }
// Bulk taken-at: same iso applied to every selected id. Mirror the
// server's manual-source flip so the badge updates without waiting on
// the reconcile fetch. has_date_warning isn't patched optimistically —
// it's recomputed server-side and the post-success invalidate carries
// it back. Per-photo errors (unsupported EXIF formats) come through
// the response shape, not as a thrown error, so onError isn't the
// place to roll back; instead we rely on the invalidate to bring back
// any rows the server skipped.
const bulkTakenAt = useMutation({
mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) =>
photosApi.bulkSetTakenAt(ids, iso),
onMutate: ({ ids, iso }) => {
const snapshot = snapshotPhotos(ids)
patchPhotos(ids, { taken_at: iso, taken_at_source: 'manual' })
return { snapshot }
},
onError: (e, _vars, ctx) => {
if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
toast.error('Date update failed', formatApiError(e))
},
onSuccess: invalidatePhotoQueries,
})
// Bulk taken-at with a per-photo iso map. Same shape as bulkTakenAt
// otherwise — patch each row to its own iso, snapshot the lot for
// rollback, reconcile via invalidate.
const bulkTakenAtMap = useMutation({
mutationFn: (map: Record<string, string>) =>
photosApi.bulkSetTakenAtMap(map),
onMutate: (map) => {
const ids = Object.keys(map)
const snapshot = snapshotPhotos(ids)
const want = new Set(ids)
// Per-id patch: can't reuse patchPhotos (single-patch primitive),
// so walk the same caches inline.
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
prev
? prev.map((p) =>
want.has(p.id)
? { ...p, taken_at: map[p.id], taken_at_source: 'manual' }
: p,
)
: prev,
)
for (const id of ids) {
const cur = queryClient.getQueryData<Photo>(['photo', id])
if (cur) {
queryClient.setQueryData<Photo>(['photo', id], {
...cur,
taken_at: map[id],
taken_at_source: 'manual',
})
}
}
return { snapshot }
},
onError: (e, _vars, ctx) => {
if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
toast.error('Date update failed', formatApiError(e))
},
onSuccess: invalidatePhotoQueries,
})
return {
bulkRating,
bulkColor,
bulkNotes,
bulkTakenAt,
bulkTakenAtMap,
invalidatePhotoQueries,
}
}