ux(showcase): smooth one-at-a-time cadence + earlier infinite-scroll trigger

Operator-flagged 2026-05-30 (round 2): the batched-loads change improved
consistency but still felt "chunky" — 5 items appeared together, then a
~200 ms API round-trip pause, then another 5. And infinite-scroll fired
too late when a single tall image (long manga page) made one masonry
column much taller than its siblings.

**Cadence (showcase store):**
- Fire all INITIAL_BATCHES fetches in PARALLEL — collapses the per-
  batch round-trip gap so all the data arrives in ~1 RTT instead of 12
  sequential RTTs.
- Trickle each response's items into images.value one at a time with
  APPEND_DELAY_MS = 80ms between each (≈ the MasonryGrid stagger
  animation, 70ms). User sees a smooth steady stream.
- fetchPage (the infinite-scroll path) uses the same trickle so its
  5-item appends also cascade one-by-one instead of popping together.
- Sequence token guards against a fast shuffle / mount-then-shuffle
  interleaving two trickles into the same images.value.
- Dropped useAsyncAction here — the parallel-fetch-then-trickle flow
  doesn't fit its single-wrap-call shape cleanly; inline loading/error
  state is clearer.

**Infinite-scroll trigger (MasonryGrid):**
- Pass `rootMargin: '2400px'` to useInfiniteScroll (was the 600px
  default). The masonry sentinel sits at the bottom of the container,
  whose height = MAX(column heights). A tall image in one column pushes
  the sentinel ~2× viewport below where the user is actually reading
  (the bottom of the SHORTER columns). 2400px ≈ 2-3 screen-heights of
  pre-emptive trigger, comfortable for typical tall manga heights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 22:48:58 -04:00
parent 810baf63ac
commit 9cd6d09e60
2 changed files with 84 additions and 23 deletions
@@ -79,9 +79,18 @@ function aspectStyle(item) {
return { aspectRatio: `${w} / ${h}` }
}
// Larger rootMargin than the composable default (600px) because the
// sentinel sits at the BOTTOM of the masonry container, whose height is
// the MAX of the column heights. A single tall image (long manga page,
// panorama) in one column pushes the sentinel way past the visible
// bottom of the SHORTER columns — the user reads the short-column
// bottoms long before the sentinel comes into view, and load-more
// fires too late. 2400px ≈ 2-3 screen-heights of pre-emptive trigger,
// comfortably covering typical tall-image heights. Operator-flagged
// 2026-05-30.
useInfiniteScroll(sentinelEl, () => {
if (props.hasMore && !props.loading) emit('load-more')
})
}, { rootMargin: '2400px' })
</script>
<style scoped>
+74 -22
View File
@@ -1,44 +1,96 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
// Operator-confirmed 2026-05-30: instead of one 60-item request, fetch
// PAGE-sized chunks sequentially so items render as each batch lands
// rather than blocking on the full 60-item response. Total initial count
// is unchanged (PAGE * INITIAL_BATCHES = 60). Infinite-scroll also pulls
// PAGE items per trigger so subsequent appends stay progressive too.
// Operator-flagged 2026-05-30 (round 2): the per-batch-then-pause cadence
// from the original sequential-fetch implementation still felt "chunky" —
// 5 items appear together, then the API round-trip pauses for ~200 ms,
// then another 5 burst in. To smooth that:
// 1. Fire all INITIAL_BATCHES fetches IN PARALLEL — collapses the per-
// batch round-trip gap (12 concurrent calls return in ~1 RTT instead
// of 12 sequential RTTs).
// 2. Trickle responses into images.value one item at a time with a
// small APPEND_DELAY_MS delay between each — the user sees a steady
// cadence rather than per-batch bursts.
// fetchPage (the infinite-scroll path) reuses the same trickle so its
// 5-item appends also cascade one-by-one instead of popping in together.
const PAGE = 5
const INITIAL_BATCHES = 12
const APPEND_DELAY_MS = 80 // ≈ the MasonryGrid stagger animation (70 ms)
function _sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
export const useShowcaseStore = defineStore('showcase', () => {
const api = useApi()
const images = ref([])
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
const loading = ref(false)
const error = ref(null)
const exhausted = ref(false)
const seen = new Set()
async function fetchPage() {
if (loading.value) return
await run(async () => {
const body = await api.get('/api/showcase', { params: { limit: PAGE } })
const fresh = body.images.filter(i => !seen.has(i.id))
if (fresh.length === 0) { exhausted.value = true; return }
for (const i of fresh) seen.add(i.id)
images.value.push(...fresh)
})
// 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)
}
}
// Reset state and fetch INITIAL_BATCHES chunks in sequence. Used by
// mount, the Shuffle button, and the R-key handler — all want the
// same progressive-cascade behavior.
// Single batch — used by infinite-scroll appends. Trickles its 5 items
// in for the same one-at-a-time cadence as the initial load.
async function fetchPage() {
if (loading.value) return
loading.value = true
error.value = null
try {
const body = await api.get('/api/showcase', { params: { limit: PAGE } })
const fresh = (body.images || []).filter(i => !seen.has(i.id))
if (fresh.length === 0) { exhausted.value = true; return }
await _trickleAppend(fresh, _seq)
} catch (e) {
error.value = e.message || String(e)
} finally {
loading.value = false
}
}
// Reset state, fire INITIAL_BATCHES fetches in parallel, then trickle
// each response's items into images.value at APPEND_DELAY_MS cadence.
// Total wall-clock = ~1 RTT (parallel) + N items × APPEND_DELAY_MS;
// the user sees a smooth steady stream throughout.
async function loadInitial() {
_seq += 1
const mySeq = _seq
images.value = []
seen.clear()
exhausted.value = false
for (let i = 0; i < INITIAL_BATCHES; i++) {
if (exhausted.value) break
await fetchPage()
error.value = null
loading.value = true
try {
const fetches = Array.from({ length: INITIAL_BATCHES }, () =>
api.get('/api/showcase', { params: { limit: PAGE } }).catch(e => {
error.value = error.value || (e.message || String(e))
return null
})
)
for (const fetchPromise of fetches) {
if (mySeq !== _seq) return
const body = await fetchPromise
if (body && body.images) await _trickleAppend(body.images, mySeq)
}
if (mySeq === _seq && images.value.length === 0) exhausted.value = true
} finally {
if (mySeq === _seq) loading.value = false
}
}