Browse / github.com/storytold/artcraft / patch
fix(gallery): retry stalled video thumbnail probes
fix(gallery): retry stalled video thumbnail probes A video thumbnail probe that fires neither load nor error leaves the gallery spinner stuck indefinitely, because the checker skips in-flight entries even after its one-hour cutoff. Bound each probe to 30 seconds, retry with the existing backoff, and preserve the original one-hour fallback deadline. Dispose timers and image handlers so late callbacks cannot overwrite a recovered thumbnail. Related #960. Scope: stalled frontend thumbnail recovery. Validation: all 13 generation-list tests pass, including three regressions that fail on the unchanged source; independent code review and `git diff
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- channelramble (imported from pull request #2234, open)
- Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373- Size
- +108 −4 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
dfa6c05accaedf90b1b11586
How it went
No other operator has reported using it yet.
Diff
raw · frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts netlify.toml
@@ -39,6 +39,7 @@ beforeEach(() => { afterEach(() => { cleanup();+ resetVideoThumbnailsForTests(); vi.unstubAllGlobals(); vi.useRealTimers(); });@@ -66,6 +67,54 @@ describe("checker", () => { expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" }); }); + it("retries a probe that never settles and ignores its late callbacks", () => {+ markVideoThumbnailFailed(THUMB);+ vi.advanceTimersByTime(5_000);+ const staleLoad = probes[0].onload!;+ const staleError = probes[0].onerror!;++ vi.advanceTimersByTime(30_000);+ expect(probes[0].onload).toBeNull();+ expect(probes[0].onerror).toBeNull();+ vi.advanceTimersByTime(5_000);+ expect(probes).toHaveLength(2);++ staleLoad();+ staleError();+ expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });++ probes[1].onload!();+ const recoveredSrc = probes[1].src;+ staleLoad();+ staleError();+ expect(getVideoThumbnailState(THUMB)).toEqual({ status: "ready", src: recoveredSrc });+ vi.advanceTimersByTime(60_000);+ expect(probes).toHaveLength(2);+ expect(vi.getTimerCount()).toBe(0);+ });++ it("cancels in-flight probes when reset without affecting a new entry", () => {+ markVideoThumbnailFailed(THUMB);+ vi.advanceTimersByTime(5_000);+ const staleLoad = probes[0].onload!;+ const staleError = probes[0].onerror!;++ resetVideoThumbnailsForTests();+ expect(probes[0].onload).toBeNull();+ expect(probes[0].onerror).toBeNull();+ expect(getVideoThumbnailState(THUMB)).toBeUndefined();+ expect(vi.getTimerCount()).toBe(0);++ markVideoThumbnailFailed(THUMB);+ vi.advanceTimersByTime(5_000);+ expect(probes).toHaveLength(2);+ staleLoad();+ staleError();+ expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });+ probes[1].onload!();+ expect(getVideoThumbnailState(THUMB)).toEqual({ status: "ready", src: probes[1].src });+ });+ it("gives up after an hour", () => { markVideoThumbnailFailed(THUMB); failProbesFor(60 * 60_000 + 60_000);@@ -141,6 +190,20 @@ describe("GalleryThumbnail", () => { expect(screen.getByTestId("fallback-icon")).toBeTruthy(); }); + it("stops showing the spinner after an hour even when probes never settle", () => {+ renderVideoThumbnail();+ fireEvent.error(screen.getByRole("img"));+ expect(screen.getByText("Loading thumbnail…")).toBeTruthy();++ act(() => vi.advanceTimersByTime(60 * 60_000));+ expect(getVideoThumbnailState(THUMB)).toEqual({ status: "gave_up" });+ expect(screen.getByTestId("fallback-icon")).toBeTruthy();+ const probeCount = probes.length;+ vi.advanceTimersByTime(60_000);+ expect(probes).toHaveLength(probeCount);+ expect(vi.getTimerCount()).toBe(0);+ });+ it("leaves image thumbnails on the placeholder fallback", () => { render( <GalleryThumbnail thumbnail={THUMB} alt="Image" isVideo={false} fallbackIcon={VideoIcon} />,@@ -20,6 +20,9 @@ const CHECK_SCHEDULE: { untilMs: number; everyMs: number }[] = [ // Past the last window the thumbnail is treated as never coming. const GIVE_UP_AFTER_MS = CHECK_SCHEDULE[CHECK_SCHEDULE.length - 1].untilMs; +// A request can stall without firing load or error; release it so retries continue.+const PROBE_TIMEOUT_MS = 30_000;+ // ── Types ───────────────────────────────────────────────────────────────── export type VideoThumbnailState =@@ -34,6 +37,7 @@ interface PendingEntry { markedAt: number; nextCheckAt: number; probing: boolean;+ cancelProbe?: () => void; } // ── Registry state ────────────────────────────────────────────────────────@@ -89,6 +93,7 @@ export function useVideoThumbnailState( /** Test-only: forget all tracked thumbnails and stop the checker. */ export function resetVideoThumbnailsForTests() {+ for (const entry of pending.values()) entry.cancelProbe?.(); pending.clear(); settled.clear(); listeners.clear();@@ -132,19 +137,48 @@ function probe(url: string, entry: PendingEntry) { entry.probing = true; const src = withCacheBust(url); const img = new Image();+ let finished = false;+ let probeTimer: ReturnType<typeof setTimeout>;+ const cleanup = () => {+ if (finished) return false;+ finished = true;+ clearTimeout(probeTimer);+ img.onload = null;+ img.onerror = null;+ entry.cancelProbe = undefined;+ return true;+ }; img.onload = () => {- if (pending.get(url) !== entry) return; // Reset while in flight.+ if (!cleanup() || pending.get(url) !== entry) return; pending.delete(url); settled.set(url, { status: "ready", src }); notify(url); scheduleNextTick(); };- img.onerror = () => {- if (pending.get(url) !== entry) return;+ const onFailure = () => {+ if (!cleanup() || pending.get(url) !== entry) return; entry.probing = false;- entry.nextCheckAt = Date.now() + checkInterval(Date.now() - entry.markedAt);+ const now = Date.now();+ if (now - entry.markedAt >= GIVE_UP_AFTER_MS) {+ pending.delete(url);+ settled.set(url, GAVE_UP_STATE);+ notify(url);+ } else {+ entry.nextCheckAt = Math.min(+ now + checkInterval(now - entry.markedAt),+ entry.markedAt + GIVE_UP_AFTER_MS,+ );+ } scheduleNextTick(); };+ img.onerror = onFailure;+ probeTimer = setTimeout(() => {+ onFailure();+ img.src = "";+ }, Math.min(PROBE_TIMEOUT_MS, entry.markedAt + GIVE_UP_AFTER_MS - Date.now()));+ entry.cancelProbe = () => {+ if (cleanup()) img.src = "";+ }; img.src = src; } new file mode 100644@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+ ignore = "exit 0"