Files
FabledCurator/frontend/src/stores/showcase.js
T
bvandeusen 412edec028
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 23s
CI / intimp (push) Successful in 3m25s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m14s
fix(showcase): don't exhaust on all-dupe batches; retry up to 8x
/api/showcase returns a random sample; after enough scrolling, an
unlucky 3-item batch can be entirely in the `seen` Set, which was
flipping `exhausted=true` and surfacing "End." mid-scroll. The
showcase is endless by intent — only a genuinely empty API response
(library has 0 images) should mark it exhausted. Tiny-library
fallback: cap retries at 8 to avoid spinning forever.
2026-06-01 23:37:21 -04:00

145 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
// Operator-flagged 2026-05-30 (round 3): the all-parallel fetch was fast
// but risked later chunks arriving first — undesirable even when each
// chunk is a random sample. Switched to a PIPELINE: only one fetch in
// flight at any moment, but the next fetch kicks off as soon as the
// previous one resolves (NOT after its trickle finishes). The next RTT
// overlaps with the current batch's trickle, hiding the per-batch
// round-trip behind the visible animation cadence. Responses arrive in
// fire-order, so no out-of-order rendering surprises.
//
// Smaller PAGE (3 vs 5) → first chunk's items appear sooner: a chunk of
// 3 trickles in 240 ms, well within one RTT, so by the time chunk 2 is
// in-hand the trickle is just finishing. Total wall-clock is roughly
// RTT + N × max(trickle_time, RTT); APPEND_DELAY_MS keeps the visible
// cadence smooth throughout.
const PAGE = 3
const INITIAL_BATCHES = 20
const APPEND_DELAY_MS = 80 // ≈ the MasonryGrid stagger animation (70 ms)
// Operator-flagged 2026-06-01: scrolling the showcase eventually hit a
// premature "End." because /api/showcase returns a *random sample* and
// after enough scrolling the `seen` Set accumulated enough to fully
// collide with a 3-item batch. The showcase is supposed to be endless;
// only a genuinely empty API response (library has zero images) should
// mark it exhausted. Retry up to FETCH_RETRY_CAP times on all-dupe
// batches; only flip `exhausted` when the API returns 0 items OR every
// retry came back dupe-only (graceful fallback for tiny libraries
// where retries will keep returning the same handful of items).
const FETCH_RETRY_CAP = 8
function _sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
export const useShowcaseStore = defineStore('showcase', () => {
const api = useApi()
const images = ref([])
const loading = ref(false)
const error = ref(null)
const exhausted = ref(false)
const seen = new Set()
// Sequence token: every call to loadInitial bumps this. _trickleAppend
// bails between items if its captured seq is no longer current — guards
// against a fast shuffle / mount-then-shuffle from interleaving two
// trickles into the same images.value.
let _seq = 0
async function _trickleAppend(items, mySeq) {
for (const item of items) {
if (mySeq !== _seq) return
if (seen.has(item.id)) continue
seen.add(item.id)
images.value.push(item)
await _sleep(APPEND_DELAY_MS)
}
}
// Single batch — used by infinite-scroll appends. Trickles its items
// in for the same one-at-a-time cadence as the initial load. Retries
// up to FETCH_RETRY_CAP times when the API's random sample comes back
// all-duplicates (the showcase is endless by design; only a genuinely
// empty API response should mark it exhausted, not an unlucky sample).
async function fetchPage() {
if (loading.value) return
loading.value = true
error.value = null
try {
for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) {
const body = await api.get('/api/showcase', { params: { limit: PAGE } })
const items = body.images || []
// API genuinely empty → library is empty / endpoint exhausted.
if (items.length === 0) { exhausted.value = true; return }
const fresh = items.filter(i => !seen.has(i.id))
if (fresh.length > 0) {
await _trickleAppend(fresh, _seq)
return
}
// All-dupes batch — keep trying. Showcase is endless by intent.
}
// Retry cap hit with zero fresh items: library is probably much
// smaller than the running `seen` set, fall back to exhausted so
// the UI stops trying. Operator can shuffle to reset `seen`.
exhausted.value = true
} catch (e) {
error.value = e.message || String(e)
} finally {
loading.value = false
}
}
function _fetchOne() {
return api.get('/api/showcase', { params: { limit: PAGE } }).catch(e => {
error.value = error.value || (e.message || String(e))
return null
})
}
// Reset state and pipeline INITIAL_BATCHES fetches: only one in flight
// at a time, but kick off the next one as soon as the previous resolves
// (NOT after its trickle finishes), so the next RTT runs concurrently
// with the current batch's trickle. Responses arrive in fire-order, so
// items always render in the order they were fetched — no out-of-order
// surprises from parallel races.
async function loadInitial() {
_seq += 1
const mySeq = _seq
images.value = []
seen.clear()
exhausted.value = false
error.value = null
loading.value = true
try {
let nextFetch = _fetchOne()
for (let i = 0; i < INITIAL_BATCHES; i++) {
if (mySeq !== _seq) return
const body = await nextFetch
// Fire the NEXT fetch immediately so its RTT overlaps the trickle.
if (i + 1 < INITIAL_BATCHES) nextFetch = _fetchOne()
if (!body || !body.images || body.images.length === 0) {
exhausted.value = true
break
}
await _trickleAppend(body.images, mySeq)
}
if (mySeq === _seq && images.value.length === 0) exhausted.value = true
} finally {
if (mySeq === _seq) loading.value = false
}
}
async function shuffle() {
await loadInitial()
}
const hasMore = computed(() => !exhausted.value)
const isEmpty = computed(
() => !loading.value && images.value.length === 0 && error.value === null
)
return { images, loading, error, hasMore, isEmpty, fetchPage, shuffle, loadInitial }
})