diff --git a/frontend/src/composables/useInflightToken.js b/frontend/src/composables/useInflightToken.js new file mode 100644 index 0000000..800cdf3 --- /dev/null +++ b/frontend/src/composables/useInflightToken.js @@ -0,0 +1,52 @@ +// Inflight-token guard for stores whose async loads can be re-triggered +// by rapid filter/navigation changes. Without this, late responses +// from a prior load overwrite the store with stale data +// (last-writer-wins, not request-order-wins). gallery.js had a +// hand-rolled `inflightId` of the same shape; this composable +// extracts it so every store can use the same pattern. +// +// Audit 2026-06-02 (workflow wf_bbe3fdb1-e62) found this missing in +// modal/suggestions/artist/downloads/directory/posts. The two most +// operator-impacting consequences: (1) modal tag mutations could +// land DELETE/POST on the wrong image when the user navigated mid- +// flight; (2) suggestions accept could push a tag to the wrong +// image AND add it to the allowlist. +// +// Usage: +// const inflight = useInflightToken() +// +// async function load() { +// const t = inflight.claim() +// const body = await api.get(...) +// if (!t.isCurrent()) return // stale — abort write +// items.value = body.items // safe to commit +// } +// +// function setFilter(f) { +// inflight.cancel() // any in-flight token is now stale +// filter.value = f +// load() // claims a fresh token +// } +// +// For multi-await flows (POST then GET, optimistic mutation then +// reconcile), check isCurrent() after EACH await — any intervening +// claim() or cancel() invalidates the prior token. +export function useInflightToken() { + let _seq = 0 + let _current = 0 + + function claim() { + _current = ++_seq + const id = _current + return { + id, + isCurrent: () => _current === id, + } + } + + function cancel() { + _current = ++_seq + } + + return { claim, cancel } +} diff --git a/frontend/src/stores/artist.js b/frontend/src/stores/artist.js index 18dee7e..8940009 100644 --- a/frontend/src/stores/artist.js +++ b/frontend/src/stores/artist.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' +import { useInflightToken } from '../composables/useInflightToken.js' import { usePostsStore } from './posts.js' const PAGE = 60 @@ -15,12 +16,17 @@ export const useArtistStore = defineStore('artist', () => { const error = ref(null) const notFound = ref(false) let started = false + // Rapid artist-to-artist navigation used to render the previous + // artist's overview/images briefly when the second load resolved + // after the third. Audit 2026-06-02. + const inflight = useInflightToken() async function load (slug) { // Cross-artist reset: clear this store AND the posts store so the new // artist doesn't briefly render with the previous artist's content // when the user is on the Posts tab. (Gallery tab uses this artist // store's own images list — cleared above.) + inflight.cancel() overview.value = null images.value = [] nextCursor.value = null @@ -29,14 +35,18 @@ export const useArtistStore = defineStore('artist', () => { error.value = null loading.value = true usePostsStore().$reset?.() + const t = inflight.claim() try { - overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`) + const body = await api.get(`/api/artist/${encodeURIComponent(slug)}`) + if (!t.isCurrent()) return + overview.value = body await loadMoreImages(slug) } catch (e) { + if (!t.isCurrent()) return if (e.status === 404) notFound.value = true else error.value = e.message } finally { - loading.value = false + if (t.isCurrent()) loading.value = false } } @@ -44,19 +54,22 @@ export const useArtistStore = defineStore('artist', () => { if (imagesLoading.value) return if (started && nextCursor.value === null) return imagesLoading.value = true + const t = inflight.claim() try { const params = { limit: PAGE } if (nextCursor.value) params.cursor = nextCursor.value const body = await api.get( `/api/artist/${encodeURIComponent(slug)}/images`, { params } ) + if (!t.isCurrent()) return images.value.push(...body.images) nextCursor.value = body.next_cursor started = true } catch (e) { + if (!t.isCurrent()) return error.value = e.message } finally { - imagesLoading.value = false + if (t.isCurrent()) imagesLoading.value = false } } diff --git a/frontend/src/stores/artistDirectory.js b/frontend/src/stores/artistDirectory.js index 111ff9f..baede1d 100644 --- a/frontend/src/stores/artistDirectory.js +++ b/frontend/src/stores/artistDirectory.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' +import { useInflightToken } from '../composables/useInflightToken.js' const PAGE = 60 @@ -13,16 +14,23 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => { const q = ref('') const platform = ref(null) let started = false + // Typed "alice" then "alice bob" used to drop the second fetch + // entirely (loading flag still true from the first), so the UI + // showed alice results while the input said "alice bob". Inflight + // token + reset() cancelling in-flight requests fixes both: the + // first response is discarded, the second is fetched. Audit 2026-06-02. + const inflight = useInflightToken() async function loadMore() { - if (loading.value) return if (started && nextCursor.value === null) return + const t = inflight.claim() await run(async () => { const params = { limit: PAGE } if (q.value) params.q = q.value if (platform.value) params.platform = platform.value if (nextCursor.value) params.cursor = nextCursor.value const body = await api.get('/api/artists/directory', { params }) + if (!t.isCurrent()) return cards.value.push(...body.cards) nextCursor.value = body.next_cursor started = true @@ -30,6 +38,7 @@ export const useArtistDirectoryStore = defineStore('artistDirectory', () => { } async function reset() { + inflight.cancel() cards.value = [] nextCursor.value = null started = false diff --git a/frontend/src/stores/downloads.js b/frontend/src/stores/downloads.js index 2a02b09..c78613f 100644 --- a/frontend/src/stores/downloads.js +++ b/frontend/src/stores/downloads.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' +import { useInflightToken } from '../composables/useInflightToken.js' export const useDownloadsStore = defineStore('downloads', () => { const api = useApi() @@ -22,6 +23,10 @@ export const useDownloadsStore = defineStore('downloads', () => { // the "active now" panel always reflects what's happening regardless of // how the operator has filtered the historical list below. const activeEvents = ref([]) + // Filter changes (applyFilter) and rapid pagination can interleave + // responses; without an inflight guard the late response from a + // prior filter overwrites the current view. Audit 2026-06-02. + const inflight = useInflightToken() function _params(extra = {}) { const out = { limit: 50, ...extra } @@ -32,8 +37,10 @@ export const useDownloadsStore = defineStore('downloads', () => { } async function loadFirst() { + const t = inflight.claim() await run(async () => { const body = await api.get('/api/downloads', { params: _params() }) + if (!t.isCurrent()) return events.value = body cursor.value = body.length ? body[body.length - 1].id : null hasMore.value = body.length === 50 @@ -42,8 +49,10 @@ export const useDownloadsStore = defineStore('downloads', () => { async function loadMore() { if (!hasMore.value || cursor.value == null) return + const t = inflight.claim() await run(async () => { const body = await api.get('/api/downloads', { params: _params({ before: cursor.value }) }) + if (!t.isCurrent()) return events.value.push(...body) cursor.value = body.length ? body[body.length - 1].id : cursor.value hasMore.value = body.length === 50 @@ -71,6 +80,9 @@ export const useDownloadsStore = defineStore('downloads', () => { } async function applyFilter(patch) { + // Drop any in-flight loadFirst/loadMore from the previous filter + // so its late response doesn't overwrite this filter's results. + inflight.cancel() filter.value = { ...filter.value, ...patch } await loadFirst() } diff --git a/frontend/src/stores/gallery.js b/frontend/src/stores/gallery.js index 1a8245a..a2c0c37 100644 --- a/frontend/src/stores/gallery.js +++ b/frontend/src/stores/gallery.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' +import { useInflightToken } from '../composables/useInflightToken.js' // Operator-confirmed 2026-05-30: fetch PAGE-sized chunks instead of one // 50-item request so items render as each batch lands. Total initial @@ -22,9 +23,12 @@ export const useGalleryStore = defineStore('gallery', () => { const timelineBuckets = ref([]) const timelineLoading = ref(false) - let inflightId = 0 + // Was a hand-rolled inflightId counter; the audit-2026-06-02 fan-out + // moved this pattern into useInflightToken so every store can share it. + const inflight = useInflightToken() async function loadInitial() { + inflight.cancel() images.value = [] dateGroups.value = [] nextCursor.value = null @@ -41,19 +45,19 @@ export const useGalleryStore = defineStore('gallery', () => { if (loading.value) return loading.value = true error.value = null - const myId = ++inflightId + const t = inflight.claim() try { const params = { limit: PAGE, ...activeFilterParam() } if (nextCursor.value) params.cursor = nextCursor.value const body = await api.get('/api/gallery/scroll', { params }) - if (myId !== inflightId) return // stale response + if (!t.isCurrent()) return images.value.push(...body.images) dateGroups.value = mergeGroups(dateGroups.value, body.date_groups) nextCursor.value = body.next_cursor } catch (e) { error.value = e.message } finally { - if (myId === inflightId) loading.value = false + if (t.isCurrent()) loading.value = false } } @@ -68,8 +72,14 @@ export const useGalleryStore = defineStore('gallery', () => { } async function jumpTo(year, month) { + // Rapid timeline-jump clicks need the same race guard as + // loadMore — first jump's late body could clobber the second + // jump's already-applied state. + inflight.cancel() + const t = inflight.claim() const params = { year, month, ...activeFilterParam() } const body = await api.get('/api/gallery/jump', { params }) + if (!t.isCurrent()) return if (body.cursor) { images.value = [] dateGroups.value = [] diff --git a/frontend/src/stores/modal.js b/frontend/src/stores/modal.js index 83f60be..2b89d76 100644 --- a/frontend/src/stores/modal.js +++ b/frontend/src/stores/modal.js @@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' +import { useInflightToken } from '../composables/useInflightToken.js' export const useModalStore = defineStore('modal', () => { const api = useApi() @@ -10,6 +11,11 @@ export const useModalStore = defineStore('modal', () => { const currentImageId = ref(null) const current = ref(null) const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) + // Tag mutations interpolate the image id into the URL after an + // await; without an inflight token, a fast prev/next can route the + // DELETE/POST to the wrong image AND the response to the wrong + // chip rail. Audit 2026-06-02. + const inflight = useInflightToken() // Post-scoped cycle. When set, prev/next cycles within this array // (used by PostCard's expanded-mosaic PostImageGrid clicks). When @@ -19,6 +25,9 @@ export const useModalStore = defineStore('modal', () => { const postImageIndex = ref(0) async function open (id, opts = {}) { + // Cancel any in-flight tag mutation or reloadTags from the + // previous image so its late response can't apply to this one. + inflight.cancel() currentImageId.value = id current.value = null // cleared upfront so it stays null on error // Update post-scoped state if caller passed it; otherwise clear so @@ -31,12 +40,16 @@ export const useModalStore = defineStore('modal', () => { postImageIds.value = null postImageIndex.value = 0 } + const t = inflight.claim() await run(async () => { - current.value = await api.get(`/api/gallery/image/${id}`) + const body = await api.get(`/api/gallery/image/${id}`) + if (!t.isCurrent()) return + current.value = body }) } async function close () { + inflight.cancel() currentImageId.value = null current.value = null error.value = null @@ -75,37 +88,74 @@ export const useModalStore = defineStore('modal', () => { } async function reloadTags () { - if (!currentImageId.value) return - const tags = await api.get(`/api/images/${currentImageId.value}/tags`) - if (current.value) current.value.tags = tags + // Capture image id at call-time. After an await, currentImageId + // may have advanced via prev/next navigation; without capture, the + // GET would target the new image and write its tags onto a chip + // rail the user didn't open. Audit 2026-06-02. + const imageId = currentImageId.value + if (!imageId) return + const t = inflight.claim() + const tags = await api.get(`/api/images/${imageId}/tags`) + if (!t.isCurrent()) return + // Only commit if the modal is still showing this image — guards + // against close() / nav clearing current between the await and now. + if (current.value && currentImageId.value === imageId) { + current.value.tags = tags + } } async function removeTag (tagId) { - if (!currentImageId.value) return + const imageId = currentImageId.value + if (!imageId) return const prev = current.value.tags + // Optimistic UI: drop the chip immediately. current.value.tags = current.value.tags.filter(t => t.id !== tagId) + // Split the two POSTs so a dismiss failure (secondary side-effect) + // doesn't roll back the successful DELETE — previously the catch + // unconditionally restored the chip rail even when only the + // dismiss had failed, so the UI lied until refresh. Audit 2026-06-02. try { - await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`) - await api.post(`/api/images/${currentImageId.value}/suggestions/dismiss`, { + await api.delete(`/api/images/${imageId}/tags/${tagId}`) + } catch (e) { + // Real failure: roll back, surface, rethrow. + if (current.value && currentImageId.value === imageId) { + current.value.tags = prev + } + toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' }) + throw e + } + // DELETE landed. The dismiss is fire-and-best-effort — log on + // failure but DON'T roll back the chip rail; the tag is gone + // server-side regardless. + try { + await api.post(`/api/images/${imageId}/suggestions/dismiss`, { body: { tag_id: tagId }, }) } catch (e) { - current.value.tags = prev - toast({ text: `Failed to remove tag: ${e.message}`, type: 'error' }) - throw e + toast({ + text: `Tag removed, but failed to dismiss suggestion: ${e.message}`, + type: 'warning', + }) } } async function addExistingTag (tagId) { - if (!currentImageId.value) return - await api.post(`/api/images/${currentImageId.value}/tags`, { + const imageId = currentImageId.value + if (!imageId) return + await api.post(`/api/images/${imageId}/tags`, { body: { tag_id: tagId, source: 'manual' }, }) await reloadTags() } async function createAndAdd ({ name, kind, fandom_id = null }) { + // Capture imageId so the post-create reloadTags / addExistingTag + // flow stays bound to the image the user clicked on, even if + // they navigated during the /api/tags POST. + const imageId = currentImageId.value + if (!imageId) return const tag = await api.post('/api/tags', { body: { name, kind, fandom_id } }) + if (currentImageId.value !== imageId) return // navigated away await addExistingTag(tag.id) } diff --git a/frontend/src/stores/posts.js b/frontend/src/stores/posts.js index ac45a6e..ab3c766 100644 --- a/frontend/src/stores/posts.js +++ b/frontend/src/stores/posts.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' +import { useInflightToken } from '../composables/useInflightToken.js' export const usePostsStore = defineStore('posts', () => { const api = useApi() @@ -19,6 +20,12 @@ export const usePostsStore = defineStore('posts', () => { const doneOlder = ref(false) const doneNewer = ref(false) const anchorId = ref(null) + // loadInitial, loadMore, loadAround, loadOlder, loadNewer all share + // one `loading` flag and previously had no inflight guard. A filter + // change (loadInitial) racing a still-in-flight loadMore would + // append the prior filter's items into the new filter's feed. + // Audit 2026-06-02. + const inflight = useInflightToken() function _qs() { const q = {} @@ -36,6 +43,7 @@ export const usePostsStore = defineStore('posts', () => { } async function loadInitial(newFilters) { + inflight.cancel() filters.value = { artist_id: newFilters?.artist_id ?? null, platform: newFilters?.platform ?? null, @@ -46,8 +54,10 @@ export const usePostsStore = defineStore('posts', () => { async function loadMore() { if (loading.value || done.value) return + const t = inflight.claim() await run(async () => { const body = await api.get('/api/posts', { params: _qs() }) + if (!t.isCurrent()) return items.value.push(...body.items) cursor.value = body.next_cursor if (body.next_cursor == null) done.value = true @@ -79,6 +89,7 @@ export const usePostsStore = defineStore('posts', () => { // posts feed; without this the older/newer scroll loaded unfiltered // global posts instead of staying in the artist's stream). async function loadAround(postId, newFilters) { + inflight.cancel() filters.value = { artist_id: newFilters?.artist_id ?? null, platform: newFilters?.platform ?? null, @@ -86,10 +97,12 @@ export const usePostsStore = defineStore('posts', () => { loading.value = true error.value = null anchorId.value = null + const t = inflight.claim() try { const body = await api.get('/api/posts', { params: _aroundParams({ around: postId }), }) + if (!t.isCurrent()) return items.value = body.items cursorOlder.value = body.cursor_older cursorNewer.value = body.cursor_newer @@ -97,47 +110,54 @@ export const usePostsStore = defineStore('posts', () => { doneNewer.value = body.cursor_newer == null anchorId.value = body.anchor_id } catch (e) { + if (!t.isCurrent()) return error.value = e } finally { - loading.value = false + if (t.isCurrent()) loading.value = false } } async function loadOlder() { if (loading.value || doneOlder.value || cursorOlder.value == null) return loading.value = true + const t = inflight.claim() try { const body = await api.get('/api/posts', { params: _aroundParams({ cursor: cursorOlder.value, direction: 'older', }), }) + if (!t.isCurrent()) return items.value.push(...body.items) cursorOlder.value = body.next_cursor if (body.next_cursor == null) doneOlder.value = true } catch (e) { + if (!t.isCurrent()) return error.value = e } finally { - loading.value = false + if (t.isCurrent()) loading.value = false } } async function loadNewer() { if (loading.value || doneNewer.value || cursorNewer.value == null) return loading.value = true + const t = inflight.claim() try { const body = await api.get('/api/posts', { params: _aroundParams({ cursor: cursorNewer.value, direction: 'newer', }), }) + if (!t.isCurrent()) return items.value.unshift(...body.items) cursorNewer.value = body.next_cursor if (body.next_cursor == null) doneNewer.value = true } catch (e) { + if (!t.isCurrent()) return error.value = e } finally { - loading.value = false + if (t.isCurrent()) loading.value = false } } diff --git a/frontend/src/stores/suggestions.js b/frontend/src/stores/suggestions.js index 574175c..6f7bb78 100644 --- a/frontend/src/stores/suggestions.js +++ b/frontend/src/stores/suggestions.js @@ -3,6 +3,7 @@ import { toast } from '../utils/toast.js' import { ref } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' +import { useInflightToken } from '../composables/useInflightToken.js' // Category display order: people first, general last. // 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only @@ -18,12 +19,25 @@ export const useSuggestionsStore = defineStore('suggestions', () => { const byCategory = ref({}) // { category: [suggestion, ...] } const { loading, error, run } = useAsyncAction({ errorAs: 'message' }) let currentImageId = null + // Audit 2026-06-02: this store had no inflight guard — a late + // /suggestions response from a prior image could overwrite + // byCategory while currentImageId pointed at a new one, and + // accept() dereferenced currentImageId AFTER an awaited POST so + // the subsequent /suggestions/accept could apply A's chosen tag + // to image B (and push it to the allowlist). Both fixed below + // by capturing imageId at call-time and gating writes on the token. + const inflight = useInflightToken() async function load(imageId) { + // Cancel any in-flight load from the previous image so its late + // response can't overwrite this image's byCategory. + inflight.cancel() currentImageId = imageId byCategory.value = {} // cleared upfront so it stays empty on error + const t = inflight.claim() await run(async () => { const body = await api.get(`/api/images/${imageId}/suggestions`) + if (!t.isCurrent()) return byCategory.value = body.by_category || {} }) } @@ -35,6 +49,11 @@ export const useSuggestionsStore = defineStore('suggestions', () => { } async function accept(suggestion) { + // Capture imageId so a mid-flight prev/next can't reroute the + // accept POST to a different image AND push the tag to that + // image's allowlist. + const imageId = currentImageId + if (imageId == null) return // Raw tags (creates_new_tag) have no canonical_tag_id; the backend's // accept endpoint needs a tag_id, so for raw tags we create the tag // first via the existing /api/tags endpoint, then accept by id. @@ -45,10 +64,14 @@ export const useSuggestionsStore = defineStore('suggestions', () => { }) tagId = created.id } - await api.post(`/api/images/${currentImageId}/suggestions/accept`, { + await api.post(`/api/images/${imageId}/suggestions/accept`, { body: { tag_id: tagId } }) - _drop(suggestion.category, s => s === suggestion) + // Only drop from THIS image's category list — if the user navigated, + // the new image has its own suggestions and this drop would corrupt them. + if (currentImageId === imageId) { + _drop(suggestion.category, s => s === suggestion) + } toast({ text: `Tagged: ${suggestion.display_name}`, type: 'success' @@ -56,14 +79,18 @@ export const useSuggestionsStore = defineStore('suggestions', () => { } async function aliasAccept(suggestion, canonicalTagId) { - await api.post(`/api/images/${currentImageId}/suggestions/alias`, { + const imageId = currentImageId + if (imageId == null) return + await api.post(`/api/images/${imageId}/suggestions/alias`, { body: { alias_string: suggestion.display_name, alias_category: suggestion.category, canonical_tag_id: canonicalTagId } }) - _drop(suggestion.category, s => s === suggestion) + if (currentImageId === imageId) { + _drop(suggestion.category, s => s === suggestion) + } toast({ text: `Aliased & tagged: ${suggestion.display_name}`, type: 'success' @@ -71,15 +98,19 @@ export const useSuggestionsStore = defineStore('suggestions', () => { } async function dismiss(suggestion) { + const imageId = currentImageId + if (imageId == null) return // Dismiss needs a tag_id; raw tags have none, so dismissing a raw // suggestion just hides it client-side (nothing to persist a rejection // against until the tag exists). if (suggestion.canonical_tag_id != null) { - await api.post(`/api/images/${currentImageId}/suggestions/dismiss`, { + await api.post(`/api/images/${imageId}/suggestions/dismiss`, { body: { tag_id: suggestion.canonical_tag_id } }) } - _drop(suggestion.category, s => s === suggestion) + if (currentImageId === imageId) { + _drop(suggestion.category, s => s === suggestion) + } } return { diff --git a/frontend/src/stores/tagDirectory.js b/frontend/src/stores/tagDirectory.js index fc1b838..4cd371a 100644 --- a/frontend/src/stores/tagDirectory.js +++ b/frontend/src/stores/tagDirectory.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' import { useAsyncAction } from '../composables/useAsyncAction.js' +import { useInflightToken } from '../composables/useInflightToken.js' const PAGE = 60 @@ -13,16 +14,21 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => { const kind = ref(null) const q = ref('') let started = false + // Same shape as artistDirectory — rapid setQuery/setKind dropped + // the second fetch because loading was still true from the first. + // Audit 2026-06-02. + const inflight = useInflightToken() async function loadMore() { - if (loading.value) return if (started && nextCursor.value === null) return + const t = inflight.claim() await run(async () => { const params = { limit: PAGE } if (kind.value) params.kind = kind.value if (q.value) params.q = q.value if (nextCursor.value) params.cursor = nextCursor.value const body = await api.get('/api/tags/directory', { params }) + if (!t.isCurrent()) return cards.value.push(...body.cards) nextCursor.value = body.next_cursor started = true @@ -30,6 +36,7 @@ export const useTagDirectoryStore = defineStore('tagDirectory', () => { } async function reset() { + inflight.cancel() cards.value = [] nextCursor.value = null started = false