From e66987f0925f3e090b6c4f7e28282486ab6b1131 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 14:07:58 -0400 Subject: [PATCH] fix(audit-g2): async race / state-leak across eight stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts gallery.js's hand-rolled inflightId pattern into a new useInflightToken composable; adopts in every store that previously had no guard against late-response overwrites or wrong-image URL interpolation. Two operator-impacting bugs the audit (workflow wf_bbe3fdb1-e62) flagged: - modal.removeTag rolled back the chip rail unconditionally even when only the secondary dismiss POST had failed — UI lied until refresh. And all tag-mutation URLs interpolated currentImageId AFTER an await, so a fast prev/next could route DELETE/POST to the wrong image. Both fixed: split try/catch (dismiss failure surfaces a warning, doesn't roll back the delete); imageId captured at call-time and used in URLs throughout. - suggestions.accept dereferenced currentImageId after the awaited POST /api/tags, so the subsequent /suggestions/accept could apply A's chosen tag to image B AND push it to B's allowlist. Fixed by capturing imageId at click-time + inflight guard on load(). Same shape across artist / downloads / artistDirectory / tagDirectory / posts stores: rapid filter/nav changes used to interleave responses (last-writer-wins). Now the late response is discarded and the most-recent request wins. Filter-change-during- search no longer drops the second fetch because the loading flag was still true from the first. gallery.js's inflightId removed in favor of the shared composable so the pattern stays consistent. --- frontend/src/composables/useInflightToken.js | 52 ++++++++++++++ frontend/src/stores/artist.js | 19 ++++- frontend/src/stores/artistDirectory.js | 11 ++- frontend/src/stores/downloads.js | 12 ++++ frontend/src/stores/gallery.js | 18 +++-- frontend/src/stores/modal.js | 74 ++++++++++++++++---- frontend/src/stores/posts.js | 26 ++++++- frontend/src/stores/suggestions.js | 43 ++++++++++-- frontend/src/stores/tagDirectory.js | 9 ++- 9 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 frontend/src/composables/useInflightToken.js 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