fix(aiui): banner follows the selected item — reset fallback state on identity change

Operator-reported: selecting a different item in the content window left the
context-surface banner showing the previous item's image. Root cause: detail
views are reused, not remounted, and useBannerFallback kept primaryIndex/
stage/apiUrl alive across the prop change — once stage hit 'api' or 'done' it
never re-evaluated. Reset is keyed on title + the primary URL set, with a
generation guard so an in-flight fetch for the old item cannot stamp its
artwork onto the new one. Heals Film/TVSeries/Book detail at once; 3
regression tests pin it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-04 03:16:26 -04:00
co-authored by Claude Opus 5
parent 6ac0ebbf0a
commit 86129d5da2
2 changed files with 92 additions and 0 deletions
@@ -0,0 +1,92 @@
// Regression pin for the stale-banner bug (operator-reported 2026-08-04):
// detail views are REUSED, not remounted, when a different item is selected in
// the content window. The composable's stage/apiUrl/primaryIndex used to
// survive that change, so the context-surface banner kept showing the previous
// item's artwork forever. These tests pin the identity-keyed reset and the
// stale-fetch generation guard.
import { describe, it, expect, vi } from 'vitest'
import { ref, nextTick } from 'vue'
import { useBannerFallback } from '../useBannerFallback'
function deferred<T>() {
let resolve!: (v: T) => void
const promise = new Promise<T>((r) => { resolve = r })
return { promise, resolve }
}
describe('useBannerFallback', () => {
it('resets to the new item\'s primary URL after the previous item resolved via API', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>([])
const apiFetch = vi.fn().mockResolvedValue({ posterUrl: 'https://api/a.jpg', backdropUrl: null })
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
// Item A has no primaries — resolves through the API path.
await nextTick()
await vi.waitFor(() => expect(bannerSrc.value).toBe('https://api/a.jpg'))
// Select item B, which has its own primary URL. Before the fix, stage was
// stuck at 'api' and bannerSrc kept returning A's artwork.
title.value = 'Item B'
primaries.value = ['https://cdn/b-poster.jpg']
await nextTick()
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
})
it('discards a stale API resolution that lands after the item changed', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>([])
const slow = deferred<{ posterUrl: string | null; backdropUrl: string | null }>()
const apiFetch = vi.fn().mockReturnValue(slow.promise)
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
await nextTick()
// Item changes while A's fetch is still in flight.
title.value = 'Item B'
primaries.value = ['https://cdn/b-poster.jpg']
await nextTick()
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
// A's fetch finally resolves — it must NOT stamp A's artwork over B.
slow.resolve({ posterUrl: 'https://api/a-late.jpg', backdropUrl: null })
await slow.promise
await nextTick()
expect(bannerSrc.value).toBe('https://cdn/b-poster.jpg')
})
it('re-runs the no-primary API kickoff for the new item after a reset', async () => {
const title = ref('Item A')
const primaries = ref<(string | null)[]>(['https://cdn/a.jpg'])
const apiFetch = vi.fn().mockResolvedValue({ posterUrl: 'https://api/b.jpg', backdropUrl: null })
const { bannerSrc } = useBannerFallback({
primaryUrls: () => primaries.value,
apiFetch,
title: () => title.value,
})
expect(bannerSrc.value).toBe('https://cdn/a.jpg')
expect(apiFetch).not.toHaveBeenCalled()
// New item with NO primaries — the kickoff must fire again post-reset.
title.value = 'Item B'
primaries.value = []
await nextTick()
await nextTick()
await vi.waitFor(() => expect(bannerSrc.value).toBe('https://api/b.jpg'))
expect(apiFetch).toHaveBeenCalledTimes(1)
})
})