Skip to content

fix(v2): batch gallery cover fetches into windows - #3765

Merged
gantoine merged 6 commits into
masterfrom
posthog-code/fix-v2-gallery-batch-fetch
Jul 16, 2026
Merged

fix(v2): batch gallery cover fetches into windows#3765
gantoine merged 6 commits into
masterfrom
posthog-code/fix-v2-gallery-batch-fetch

Conversation

@gantoine

Copy link
Copy Markdown
Member

Description
Explain the changes or enhancements you are proposing with this pull request.

The v2 gallery is dramatically slower than v1 to load cover thumbnails on low-power devices. On an Amlogic S905W2 TV box, a grid of ~108 covers takes 1-2 minutes to fill; the v1 UI loaded the same library in 1-2 seconds.

Root cause: v2 hydrated each visible card with its own GET /roms/{id}/simple request, i.e. one HTTP round trip and one DB lookup per card. On a full grid that fires ~100 requests almost simultaneously. The browser's per-host connection limit (~6) serializes them into ~17 slow waves, and each wave pays full HTTP + JSON + a discrete backend query. v1 instead fetched a whole page (GET /roms?limit=72&offset=...) in 1-2 batched requests.

Fix: route both layouts' hydration through the store's existing fetchWindowAt, which aligns each visible position to its shared 72-item window and dedupes against pending/loaded windows:

  • Grid: GalleryShell.syncFetches now calls fetchWindowAt(p) per visible position instead of per-card fetchRomAt(p). Positions in the same window collapse to a single request (the store adds the offset to pendingWindows synchronously before its first await, so concurrent calls dedupe).
  • List: GameListRow mount now calls fetchWindowAt(position).
  • Removes the now-unused fetchRomAt / cancelFetchAt store actions and their per-position AbortController bookkeeping. Per-window fetches are still aborted en masse by invalidateWindows / resetGallery on context switch.

A full viewport now resolves in a handful of paginated requests rather than one per card, matching v1's batched loading.

Note: two secondary GPU costs also compound this on weak GPUs but are out of scope here: the always-on full-viewport filter: blur(28px) background layer, and the per-card filter: blur(16px) cover reveal animation. Happy to follow up on those separately.

AI assistance disclosure: This change was authored with AI assistance (PostHog Code / Claude). The diagnosis, code changes, and comment updates were AI-generated and human-reviewed.

Checklist
Please check all that apply.

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

Screenshots (if applicable)

N/A (no visual change; loading behavior only).


Created with PostHog Code

The v2 gallery hydrated each visible card with its own
GET /roms/{id}/simple request (one HTTP round trip and one DB lookup
per card). On a full grid that meant ~100 simultaneous requests, which
the browser's per-host connection cap serialized into slow waves,
taking 1-2 minutes to fill the grid on low-power devices. v1 loaded the
same library in 1-2 paginated requests.

Route both grid (GalleryShell viewport sync) and list (GameListRow
mount) hydration through the store's existing fetchWindowAt, which
aligns each visible position to its shared 72-item window and dedupes
against pending/loaded windows. A full viewport now resolves in a
handful of paginated requests instead of one per card. Drops the
now-unused per-card fetchRomAt/cancelFetchAt store actions.

Generated-By: PostHog Code
Task-Id: 6690877a-9c32-4c91-a78b-3d6fd3851fb7
Copilot AI review requested due to automatic review settings July 16, 2026 12:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR batches v2 gallery cover hydration into shared windows. The main changes are:

  • Routes grid and list hydration through fetchWindowAt.
  • Deduplicates visible positions into 72-item requests.
  • Removes per-position fetch actions and cancellation state.

Confidence Score: 4/5

Visible cards can remain unloaded after a transient window failure.

  • A failed request is cleared from pending state, but a stationary viewport does not automatically invoke the fetch again.
  • Fast scrolling can leave requests for invisible windows running, reducing the intended performance gain.
  • No dangling references to the removed store actions were identified.

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

Important Files Changed

Filename Overview
frontend/src/v2/components/Gallery/GalleryShell.vue Switches viewport hydration to window requests, but visible failed windows lack an immediate retry and departed windows continue loading.
frontend/src/v2/components/Gallery/GameListRow.vue Switches list-row mounting to shared window hydration and removes per-row cancellation.
frontend/src/v2/stores/galleryRoms.ts Removes the obsolete per-position fetch and cancellation actions while retaining window-level invalidation.

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(v2): batch gallery cover fetches int..." | Re-trigger Greptile

Comment thread frontend/src/v2/components/Gallery/GalleryShell.vue Outdated
Comment thread frontend/src/v2/components/Gallery/GalleryShell.vue Outdated
@gantoine
gantoine requested a review from zurdi15 July 16, 2026 12:45
gantoine added 2 commits July 16, 2026 08:55
syncFetches only runs on viewport changes, so a window whose fetch
failed once would leave up to WINDOW_SIZE (72) visible cards stranded as
skeletons on a static viewport until the user scrolled or the gallery
context changed. Since only visible windows are ever fetched, a failed
window was always visible when it failed.

fetchWindowAt now schedules a bounded, backing-off refetch (up to 3
attempts, 2s/4s/6s) on failure so a transient blip self-heals. Retry
timers and attempt counts are cleared on success and aborted alongside
in-flight requests on any gallery-context switch, so we never retry
against a stale context.

Generated-By: PostHog Code
Task-Id: 6690877a-9c32-4c91-a78b-3d6fd3851fb7
Switching from per-card to per-window fetching dropped the departure
cancellation the per-card path had: scrolling through a large library
started a request per 72-item window encountered and left every departed
window downloading and applying in the background, the wasted network /
backend / render work this change set out to avoid on low-power devices.

Add store.syncVisibleWindows(positions): it starts the windows covering
the visible positions (deduped in fetchWindowAt) and aborts any in-flight
or retry-pending window that no longer covers one. The shell's debounced
viewport sync now delegates to it. Also abort in-flight windows when the
gallery unmounts (onBeforeRouteLeave doesn't reset the store), keeping
the loaded cache so returning is still instant. Drops the shell's dead
visiblePositions bookkeeping.

Generated-By: PostHog Code
Task-Id: 6690877a-9c32-4c91-a78b-3d6fd3851fb7
@Spinnich

Copy link
Copy Markdown
Contributor

The batching direction gets a strong +1 from me. I independently measured the
same mechanism this morning on a replica of my production DB (92,800 roms) while
investigating #3771: ~44 ms of actual backend work per /simple call versus
300-1300 ms observed per request in the browser, i.e. the per-card latency was
connection-cap queuing, exactly as this PR describes. One window query hydrates
72 rows in 0.02s at the DB. Batching is the right fix.

Three things before merge, first one matters most:

1. Window fetches carry the full sidecar payload and (when scoped) full
sidecar compute.
_buildRequestParams doesn't pass with_char_index=false /
with_filter_values=false, and rom_id_index is always serialized into every
GET /roms response. So after this PR:

Suggestion: once metadataLoaded is true (the bootstrap or window 0 already
populated the filter panel, alpha strip, and id index), window fetches should
send with_char_index=false and with_filter_values=false. rom_id_index
needs a backend opt-out first, which is exactly #3770; this PR makes that
param more valuable, worth wiring here once it exists.

2. Cancel/re-want race strands skeletons. cancelWindow aborts the
controller but the offset stays in pendingWindows until the aborted fetch's
finally runs. Flick-scroll away and back within that gap and
syncVisibleWindows calls fetchWindowAt(offset), which early-returns on the
stale pendingWindows entry; the abort then lands, finally clears the entry,
and nothing refetches until the next viewport change. The removed per-card
cancelFetchAt deleted its controller key synchronously, so it didn't have
this race. Fix: have cancelWindow remove the offset from pendingWindows
(and the controller map) synchronously, mirroring the old behavior.

3. Question: list mode's row-mount fetches vs the shell's window sync.
GameListRow fires fetchWindowAt on mount (overscan rows included), while
syncVisibleWindows cancels any pending window not covering a visible
position. If the shell's sync also runs in list mode, an overscan row's window
can be fetched on mount and then immediately cancelled by the next sync, and
since rows only fetch on mount, it won't retry until the viewport changes. If
collectVisiblePositions includes the overscan range this is moot; worth
confirming.

Nit: after MAX_WINDOW_RETRIES is exhausted, the retryCounts entry lingers
until a context switch; and a retry timer that fires after the user stopped
scrolling refetches a window that may no longer be visible. Both harmless,
mentioning for completeness.

(Review AI-assisted, Claude Code; measurements from the #3768 investigation
replica.)

gantoine added 3 commits July 16, 2026 18:55
Once the bootstrap or window 0 has populated the filter panel, alpha
strip, and id index, later window fetches only need their paged items.
Send with_char_index=false and with_filter_values=false so the backend
doesn't recompute those aggregations for every window.

Generated-By: PostHog Code
Task-Id: daf23950-b84a-4537-971c-3f85425c172c
@gantoine
gantoine merged commit 484b1d3 into master Jul 16, 2026
9 checks passed
@gantoine
gantoine deleted the posthog-code/fix-v2-gallery-batch-fetch branch July 16, 2026 23:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants