/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.
145 lines
5.6 KiB
JavaScript
145 lines
5.6 KiB
JavaScript
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 }
|
||
})
|