fix(ui): a picture that fails to load shows the card's own frame, then retries

A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a
Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the
channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the
browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the
broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses
a bare <img>.
This commit is contained in:
2026-10-14 20:51:08 +02:00
parent 06e8291896
commit 383d394e32
31 changed files with 435 additions and 235 deletions
@@ -0,0 +1,91 @@
import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "react";
// Every picture the app loads over the network goes through here (lint-enforced: a bare <img> is
// refused everywhere else), because a picture that fails left the browser's broken-image mark on a
// card, and each component that noticed had hand-rolled its own cure. What they learned, in one place:
//
// - No Referer. Google's image CDNs (yt3.ggpht.com, lh3.googleusercontent.com) refuse a request that
// carries one from our origin — not only in a burst (measured 2026-09-26). Our own endpoints never
// read it, so it is dropped for every picture rather than guessed per host.
// - A failed load is retried, with a cache-buster: the browser NEGATIVE-CACHES the miss, so asking for
// the same URL again would only replay the failure. Both Google CDNs and our API ignore the extra
// `r` parameter (measured 2026-10-14). A 429 from a burst of cards heals this way.
// - While there is no picture — none given, a retry still waiting, every retry spent — the caller's
// `fallback` stands in (nothing by default: the card's own empty frame, which is what a card with no
// thumbnail has always shown). Never the broken-image mark.
// - The failure is remembered BY URL, in render: these sit in virtualized grids whose slots are recycled,
// and a failure stored as a flag an effect resets painted one item's fallback over the next one's
// picture for a frame (ui/Avatar had that defect).
/** How many times a failed picture is asked for again, and the wait before the first retry (it
* doubles each time): 1 s, 2 s, 4 s, then the fallback for good. */
const IMAGE_RETRIES = 3;
const FIRST_RETRY_MS = 1000;
/** `url` with a cache-buster for retry `attempt` (0 = the URL as given). An inline `data:`/`blob:` URL
* is not fetched, so it has no cache to bust. */
function bustImageUrl(url: string, attempt: number): string {
if (attempt === 0 || /^(data|blob):/.test(url)) return url;
return `${url}${url.includes("?") ? "&" : "?"}r=${attempt}`;
}
/** The same for every candidate of a `srcset` ("url 200w, url2 400w"). */
function bustSrcSet(srcSet: string, attempt: number): string {
if (attempt === 0) return srcSet;
return srcSet
.split(",")
.map((part) => {
const [url = "", ...descriptor] = part.trim().split(/\s+/);
return [bustImageUrl(url, attempt), ...descriptor].join(" ");
})
.join(", ");
}
type Miss = { src: string; attempt: number; waiting: boolean };
export default function RemoteImage({
src,
srcSet,
fallback = null,
retries = IMAGE_RETRIES,
...img
}: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "srcSet" | "onError" | "referrerPolicy"> & {
src: string | null | undefined;
srcSet?: string;
/** What stands in while there is no picture. */
fallback?: ReactNode;
/** Fewer retries for a picture whose failure is an answer, not a hiccup — a file that is not there
* yet is better shown as its fallback now than after seven seconds of retries. */
retries?: number;
}) {
const [miss, setMiss] = useState<Miss | null>(null);
// Forgotten once `src` moves off it — convergent, so it belongs in render (see above).
if (miss !== null && miss.src !== src) setMiss(null);
const current = miss !== null && miss.src === src ? miss : null;
const attempt = current?.attempt ?? 0;
const waiting = current?.waiting === true;
useEffect(() => {
if (!waiting) return;
const timer = window.setTimeout(
() => setMiss((m) => (m !== null && m.src === src ? { ...m, waiting: false } : m)),
FIRST_RETRY_MS * 2 ** (attempt - 1),
);
return () => window.clearTimeout(timer);
}, [waiting, attempt, src]);
const spent = current !== null && !current.waiting && current.attempt > retries;
if (!src || waiting || spent) return <>{fallback}</>;
return (
// eslint-disable-next-line no-restricted-syntax -- the one <img> the rest of the app goes through
<img
{...img}
// A fresh element per attempt: the failed one is done, and a new src on it would not refire.
key={attempt}
src={bustImageUrl(src, attempt)}
srcSet={srcSet === undefined ? undefined : bustSrcSet(srcSet, attempt)}
referrerPolicy="no-referrer"
onError={() => setMiss({ src, attempt: attempt + 1, waiting: attempt < retries })}
/>
);
}