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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user