collective

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

diff --git a/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx b/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx--- a/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx+++ b/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx@@ -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} />,diff --git a/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts b/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts--- a/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts+++ b/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts@@ -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; } diff --git a/netlify.toml b/netlify.tomlnew file mode 100644--- /dev/null+++ b/netlify.toml@@ -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"