Skip to content

Commit 0f3cc56

Browse files
authored
Merge pull request #3824 from rommapp/fix/v2-gallery-batch-hydration-cap
fix(v2): batch gallery hydration into windows and cap concurrency
2 parents b3318d0 + b6ea5fc commit 0f3cc56

5 files changed

Lines changed: 555 additions & 165 deletions

File tree

frontend/src/services/api/rom.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,10 @@ export interface GetRomsParams {
183183
playerCountsLogic?: string | null;
184184
metadataProvidersLogic?: string | null;
185185
tagsLogic?: string | null;
186-
// Skip the char index / filter-value aggregations server-side
186+
// Skip the char index / filter-value / id-index aggregations server-side
187187
withCharIndex?: boolean;
188188
withFilterValues?: boolean;
189+
withRomIdIndex?: boolean;
189190
// Cancel an in-flight request
190191
signal?: AbortSignal;
191192
}
@@ -236,6 +237,7 @@ async function getRoms({
236237
tagsLogic = null,
237238
withCharIndex = undefined,
238239
withFilterValues = undefined,
240+
withRomIdIndex = undefined,
239241
signal = undefined,
240242
}: GetRomsParams) {
241243
const params = {
@@ -346,6 +348,9 @@ async function getRoms({
346348
...(withFilterValues !== undefined
347349
? { with_filter_values: withFilterValues }
348350
: {}),
351+
...(withRomIdIndex !== undefined
352+
? { with_rom_id_index: withRomIdIndex }
353+
: {}),
349354
};
350355

351356
return api.get<GetRomsResponse>(`/roms`, {

frontend/src/v2/components/Gallery/GalleryShell.vue

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -554,23 +554,20 @@ const currentLetter = computed<string>(() => {
554554
return "";
555555
});
556556
557-
// Per-card viewport-driven fetch. The shell tracks which positions are
558-
// currently visible (from the rows in `viewportRange`) and keeps
559-
// `byPosition` in sync via per-card `GET /roms/{id}/simple` calls — pure
560-
// by-id lookups on the backend, much faster than the paginated
561-
// `getRoms(limit/offset)` pipeline. Each fetch is independent, so covers
562-
// stream in as their individual responses land.
557+
// Viewport-driven windowed fetch. The shell collects which positions are
558+
// currently visible (from the rows in `viewportRange`, for both grid and
559+
// list layouts) and hands them to the store's `syncVisibleWindows`, which
560+
// aligns each to its shared 72-item window, dedupes, starts the windows
561+
// covering the viewport, and cancels any that scrolled out of view.
562+
// Batching visible cards into a handful of paginated `getRoms` requests
563+
// (instead of one request per card) is what keeps a fast scroll — or two
564+
// users scrolling at once — from flooding the single-worker backend.
563565
//
564-
// No idle-time prefetch: when the user stops scrolling, no new requests
565-
// are fired. Cards already in the viewport that are still missing keep
566-
// loading; everything off-screen waits until the user scrolls there.
567-
//
568-
// Cancellation: positions that leave the viewport while their fetch is in
569-
// flight are aborted via `cancelFetchAt`. A small debounce on viewport
570-
// changes prevents fire-and-cancel storms during smooth scrolling — only
571-
// when the viewport settles for `FETCH_DEBOUNCE_MS` do we sync.
566+
// A small debounce on viewport changes prevents fire-and-cancel storms
567+
// during smooth scrolling — only when the viewport settles for
568+
// `FETCH_DEBOUNCE_MS` do we sync. Both layouts share this one path, so the
569+
// list is debounced too (list rows no longer self-fetch on mount).
572570
const FETCH_DEBOUNCE_MS = 80;
573-
const visiblePositions = new Set<number>();
574571
let fetchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
575572
let pendingRange: { first: number; last: number } | null = null;
576573
@@ -583,34 +580,20 @@ function collectVisiblePositions(range: {
583580
const items = virtualItems.value;
584581
for (let i = range.first; i <= range.last; i++) {
585582
const it = items[i];
586-
if (!it || it.kind !== "row") continue;
587-
for (let p = it.startPosition; p < it.endPosition; p++) out.add(p);
583+
if (!it) continue;
584+
// Grid rows fan out into a contiguous run of card positions; list rows
585+
// carry a single position each.
586+
if (it.kind === "row") {
587+
for (let p = it.startPosition; p < it.endPosition; p++) out.add(p);
588+
} else if (it.kind === "list-row") {
589+
out.add(it.position);
590+
}
588591
}
589592
return out;
590593
}
591594
592595
function syncFetches(range: { first: number; last: number }) {
593-
const next = collectVisiblePositions(range);
594-
595-
// Cancel positions that left the viewport before their fetch resolved.
596-
// The store's per-position controller handles the network abort;
597-
// idempotent if nothing was in flight.
598-
for (const p of visiblePositions) {
599-
if (!next.has(p) && !galleryRoms.byPosition.has(p)) {
600-
galleryRoms.cancelFetchAt(p);
601-
}
602-
}
603-
604-
// Fire per-card fetches for positions that just entered. The store
605-
// dedupes against in-flight + already-loaded internally.
606-
for (const p of next) {
607-
if (!galleryRoms.byPosition.has(p)) {
608-
void galleryRoms.fetchRomAt(p);
609-
}
610-
}
611-
612-
visiblePositions.clear();
613-
for (const p of next) visiblePositions.add(p);
596+
galleryRoms.syncVisibleWindows(collectVisiblePositions(range));
614597
}
615598
616599
function scheduleFetchSync(range: { first: number; last: number }) {
@@ -663,9 +646,9 @@ function scrollToLetter(letter: string) {
663646
toolbarHeight.value + (layout.value === "list" ? LIST_HEADER_HEIGHT : 0);
664647
scrollerRef.value?.scrollToIndex(idx, { smooth: true, stickyOffset });
665648
// The viewport-driven fetch sync handles the destination — once the
666-
// smooth scroll settles, `update:viewportRange` fires and the cards
667-
// at the landing zone start loading via `syncFetches` (grid) or via
668-
// each `GameListRow`'s onMounted (list). No manual prefetch needed.
649+
// smooth scroll settles, `update:viewportRange` fires and the windows at
650+
// the landing zone start loading via `syncFetches` (both layouts). No
651+
// manual prefetch needed.
669652
}
670653
671654
// ── Search filter (debounced) ───────────────────────────────────────
@@ -792,12 +775,10 @@ onBeforeUnmount(() => {
792775
gallerySelection.clear();
793776
if (searchDebounce) clearTimeout(searchDebounce);
794777
if (fetchDebounceTimer) clearTimeout(fetchDebounceTimer);
795-
// When leaving the gallery entirely, stop any per-card fetches still in
796-
// flight so navigating away mid-scroll doesn't keep the network /
797-
// backend busy. Keeps the hydrated cache so returning to the same
798-
// gallery is instant.
778+
// When leaving the gallery entirely, stop any in-flight window fetches so
779+
// navigating away mid-scroll doesn't keep the network / backend busy.
780+
// Keeps the hydrated cache so returning to the same gallery is instant.
799781
galleryRoms.abortInFlight();
800-
visiblePositions.clear();
801782
inflowResizeObserver?.disconnect();
802783
inflowResizeObserver = null;
803784
// Drop the debug stats so the overlay doesn't show stale gallery numbers

frontend/src/v2/components/Gallery/GameListRow.vue

Lines changed: 5 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,6 @@
22
// GameListRow — single row of the list-mode gallery.
33
//
44
// Owns:
5-
// * Per-row lazy fetch: a watch fires `fetchRomAt(position)` whenever
6-
// the row is missing its rom but the store knows the id — covering
7-
// both mount ("entered the overscan window") and a context refresh
8-
// that clears `byPosition` under a still-mounted row. onUnmount aborts
9-
// the fetch via `cancelFetchAt(position)` so a fast scroll past
10-
// mid-flight doesn't keep server work queued for invisible rows.
11-
// Mirror of the per-card grid flow, with the same "row mount =
12-
// entered viewport" contract via the shell's RVirtualScroller
13-
// overscan window.
14-
//
155
// * Skeleton ↔ real swap — when `getRomAt(position)` returns null the
166
// row paints skeleton placeholders in every column; once the fetch
177
// resolves it flips to the real cells. Same row height in both
@@ -29,7 +19,7 @@ import {
2919
RSkeletonBlock,
3020
RTooltip,
3121
} from "@v2/lib";
32-
import { computed, onBeforeUnmount, watch } from "vue";
22+
import { computed } from "vue";
3323
import { useI18n } from "vue-i18n";
3424
import { useRouter } from "vue-router";
3525
import storePlatforms from "@/stores/platforms";
@@ -59,13 +49,13 @@ defineOptions({ inheritAttrs: false });
5949
const PILLS_VISIBLE = 4;
6050
6151
interface Props {
62-
/** Absolute position in the active gallery (0-indexed). Drives the
63-
* row's per-row fetch + serves as the lookup key into the store's
64-
* `byPosition` map. Pass either this or `rom`, not both. */
52+
/** Absolute position in the active gallery (0-indexed). Serves as the
53+
* lookup key into the store's `byPosition` map; the shell drives the
54+
* windowed fetch that fills it. Pass either this or `rom`, not both. */
6555
position?: number;
6656
/** Static ROM data — used by non-gallery surfaces (Settings → Missing
6757
* games) that already own the rom list. When provided, the row skips
68-
* the galleryRoms position lookup and the per-row lazy fetch. */
58+
* the galleryRoms position lookup. */
6959
rom?: SimpleRom | null;
7060
/** Cover variant — when the browser supports webp the thumb URL is
7161
* rewritten to .webp before the request. Wired from the shell so the
@@ -263,39 +253,6 @@ function onRowPointerEnd() {
263253
if (isStatic.value) return;
264254
selectionInput.handlePointerEnd();
265255
}
266-
267-
// Kick the per-row fetch whenever this position is missing its rom but
268-
// the store knows its id. This covers both mount ("entered the overscan
269-
// window") and a context refresh: a filter / search / sort / scan clears
270-
// `byPosition` and repopulates `romIdIndex`, but the row can stay mounted
271-
// at the same position — without this watch it would sit on skeletons
272-
// until it scrolled out and remounted. The store dedupes against
273-
// in-flight + already-loaded, so re-runs are cheap. Guarding on the id
274-
// (not just null rom) also re-fetches when a resort lands a different rom
275-
// at the same position.
276-
watch(
277-
() => {
278-
if (isStatic.value || props.position === undefined) return null;
279-
if (galleryRoms.byPosition.has(props.position)) return null;
280-
return galleryRoms.romIdIndex[props.position] ?? null;
281-
},
282-
(romId) => {
283-
if (romId != null && props.position !== undefined) {
284-
void galleryRoms.fetchRomAt(props.position);
285-
}
286-
},
287-
{ immediate: true },
288-
);
289-
290-
onBeforeUnmount(() => {
291-
if (isStatic.value || props.position === undefined) return;
292-
// Left the overscan window before the fetch resolved — abort so the
293-
// server doesn't keep building a row the user already scrolled past.
294-
// Idempotent if nothing was in flight.
295-
if (!galleryRoms.byPosition.has(props.position)) {
296-
galleryRoms.cancelFetchAt(props.position);
297-
}
298-
});
299256
</script>
300257

301258
<template>

0 commit comments

Comments
 (0)