From 86129d5da205ad5dbe5cf105a2e74d3b1b490296 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 4 Aug 2026 03:16:26 -0400 Subject: [PATCH] =?UTF-8?q?fix(aiui):=20banner=20follows=20the=20selected?= =?UTF-8?q?=20item=20=E2=80=94=20reset=20fallback=20state=20on=20identity?= =?UTF-8?q?=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/useBannerFallback.test.ts | 92 ++++++++++++++++++ .../app/src/composables/useBannerFallback.ts | Bin 3114 -> 4693 bytes 2 files changed, 92 insertions(+) create mode 100644 aiui/packages/app/src/composables/__tests__/useBannerFallback.test.ts diff --git a/aiui/packages/app/src/composables/__tests__/useBannerFallback.test.ts b/aiui/packages/app/src/composables/__tests__/useBannerFallback.test.ts new file mode 100644 index 00000000..24a9a974 --- /dev/null +++ b/aiui/packages/app/src/composables/__tests__/useBannerFallback.test.ts @@ -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() { + let resolve!: (v: T) => void + const promise = new Promise((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) + }) +}) diff --git a/aiui/packages/app/src/composables/useBannerFallback.ts b/aiui/packages/app/src/composables/useBannerFallback.ts index 8e228ebe6319f891d5a0919a0fac1c9d616badbf..83e0854cb1a2e2935270d34b4b32ca739bd31631 100644 GIT binary patch delta 1663 zcmaJ>!KxfZ6xA0(;ta-x7$PEE!O-2ju_tTe%j1O*gh&ugbP-(Cbl;w-Om$ULRXxoR z!nklPN-O#e?j(QUKZtu5F5SD*Q`J4Z379ktGt;-~+;h*p=l*i-_xHcMcCiz_Sp9nS z(S;q+d``F9y5W+#6>HwGCBI^7b;(w3rCyTsTvM?Uddco42~xr{iALUKppa!}RO_YD zlL%H>Q{zx$c_U5h#@nWD1WuAz3aOn(3W-0uDhc1EbcL`bC|H5qfq)p%oELQyNCZws z;W)@7xN3c8?3#@BCdM>(_`^9!DE72ODF6d2I-wiSnhRGhUM)<>j0j$x$NTp3y&Xh& zfCLAX205hSh+V}(JAq;}RZ1>bp8ov$2gE`V0~6%kKLA^>GJ5G~B=?*cH>tFyK`zu^ zMzVf~+@XN6IMapd_YdICHKtT3%c9(dcOJPR+F~17#-_5(2=8?ngxEi_qBNBK-pOI% z86bRXL#iX9{@=;zvwtq%dUcmp(n2T8VFKvv{v8sP$1kC`I0`lgDn%I_4>HM;wU>TN zBa^5?mjxzOtBcr-wJg@As?HkuC8q7Z`P9l<*ln88lSsHQ{n4fCy`IeapYi!^^7cDK z|KB*A@Bf(O9}~U2zCan0pqHU%2joktW%O{*~ov*_SB3y4~Bthn&F0E1N(qoIC)lYR$BW0r%dAg)J*I;l(3 zjdRWLuW8({1;R1Mu#CqTxN#HEq8(g3Ko&5#ablqu=7ieV5P@MZfNSkWZg@s+aOy&6 zy#u{9H=f+eAaS(!0Q8(yfE7o71EjsLdS76;LXF(xXNJnunltu z?e6hl4*+2=U>{&a)sAr|RNoBqM-M;y1bX=oK^S!oI|vttk**@bUOPyK3=~pqsc3D` zHI8UoqpI|-RQ3G97oW40?B=(PSyLI?7mX0DkxsC8iH5OxWSZa%mj_;)W;4q3eE)8w zhq7Fn<+W(iHjG+Jr>B%8*;n~vBXyd*l4LW`+d~dkqJt%Od>q5Huebl}(v_ndlm7eZ vTgx#=GM*Sz%r`={j76zirOqR9<%fQqP$iV(G4kU14Wyn1Zdw2L^rQa(0#qT6 delta 151 zcmcbrvPxot#76l@=FM>|ysVSEIGrYc<+a|-%WcCbke{cJmRVF>qEM8Ymy%kC2A*23fTYv4+AV6