fix(showcase): reveal each tile only once its image is fully decoded
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 19s
CI / intimp (push) Successful in 3m33s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m23s

The buffered cascade revealed tiles on an 80ms timer regardless of image
load, so the flip-in animation played on a gray placeholder and the thumbnail
popped in afterward. Worse, MasonryGrid ALSO applied a per-index
animation-delay (index×70ms) that compounded on top of the insert cadence,
so the cascade visibly dragged and desynced as it grew.

Now the producer preloads each queued thumbnail (decode pipelined ahead) and
the consumer awaits that decode before pushing the item — every tile animates
in fully loaded, strictly one at a time. Drop the compounding CSS stagger;
the store's one-item-at-a-time push is the sole pacer, so each tile animates
the instant it mounts. New utils/preloadImage.js (load+decode+timeout gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 07:19:44 -04:00
parent ae569c0f9a
commit 3a4270e6be
4 changed files with 85 additions and 23 deletions
+30 -9
View File
@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '../composables/useApi.js'
import { preloadImage } from '../utils/preloadImage.js'
// Buffered producer/consumer so the cascade cadence is decoupled from fetch
// latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch
@@ -9,15 +10,21 @@ import { useApi } from '../composables/useApi.js'
// sometimes-cold blocks; RTT jitter) the animation starved and the view
// "burped" out a clump of images. Now:
// - PRODUCER (_fill): races ahead fetching batches into `queue` up to a
// target depth, refilling whenever the queue dips below BUFFER_MIN.
// - CONSUMER (_drain): pops ONE item every CADENCE_MS, independent of when
// fetches land. It only waits if the buffer genuinely starves.
// As long as fetches keep up on average (a 3-row fetch is usually well under
// the BUFFER_MIN×CADENCE runway), images appear at a perfectly even pace.
// The very first image still waits on the first fetch (a cold TABLESAMPLE is a
// separate, query-side concern); everything after it is buffer-smoothed.
// target depth, refilling whenever the queue dips below BUFFER_MIN. It
// also kicks off the image PRELOAD for each queued item so decoding
// pipelines ahead of the reveal.
// - CONSUMER (_drain): pops ONE item, WAITS for its thumbnail to be fully
// decoded, then reveals it — at most one per CADENCE_MS. Because the
// producer preloads ahead, the decode-wait is usually already satisfied,
// so the reveal stays evenly paced without idling the network.
// The decode-gate is what makes the showcase's signature cascade land each
// tile fully-loaded (the flip-in animates a real image, never a gray
// placeholder); the single-item reveal keeps it strictly one-at-a-time
// (operator-flagged 2026-06-04). The very first image still waits on the first
// fetch + decode (a cold TABLESAMPLE is a separate, query-side concern);
// everything after it is buffer-smoothed.
const PAGE = 3
const CADENCE_MS = 80 // ≈ the MasonryGrid 70ms stagger animation
const CADENCE_MS = 80 // floor between fully-loaded reveals
const PRIME = 6 // items buffered before the drain starts
const BUFFER_TARGET = 30 // producer tops the queue up to this
const BUFFER_MIN = 12 // ...and refills once the queue dips below this
@@ -42,12 +49,21 @@ export const useShowcaseStore = defineStore('showcase', () => {
// Internal buffer (not reactive — the consumer is what feeds the UI via
// images.value).
let queue = []
// id -> Promise that settles when the thumbnail is paint-ready. Started by
// the producer so decoding runs ahead of the reveal; awaited by the consumer
// so no tile is shown before its image is loaded.
let _preloads = new Map()
// Sequence token: shuffle / re-mount bumps it so in-flight producers and
// the drain bail instead of interleaving two runs into one images list.
let _seq = 0
let _filling = false
let _draining = false
function _preload(item) {
if (!_preloads.has(item.id)) _preloads.set(item.id, preloadImage(item.thumbnail_url))
return _preloads.get(item.id)
}
// One batch, retried while the random sample comes back all-duplicates.
// Returns the fresh items, or null when the API is genuinely empty.
async function _fetchFresh(mySeq) {
@@ -79,6 +95,7 @@ export const useShowcaseStore = defineStore('showcase', () => {
if (mySeq !== _seq) return
if (batch === null) { exhausted.value = true; return }
queue.push(...batch)
batch.forEach(_preload) // pipeline decoding ahead of the reveal
}
} finally {
_filling = false
@@ -104,7 +121,10 @@ export const useShowcaseStore = defineStore('showcase', () => {
}
if (mySeq !== _seq) return
if (queue.length === 0) return // exhausted + empty
images.value.push(queue.shift())
const item = queue.shift()
await _preload(item) // reveal only once the thumbnail is fully decoded
if (mySeq !== _seq) return
images.value.push(item)
await _sleep(CADENCE_MS)
}
} finally {
@@ -120,6 +140,7 @@ export const useShowcaseStore = defineStore('showcase', () => {
const mySeq = _seq
images.value = []
queue = []
_preloads = new Map()
seen.clear()
exhausted.value = false
error.value = null