import { useEffect, useState, type ImgHTMLAttributes, type ReactNode } from "react"; // Every picture the app loads over the network goes through here (lint-enforced: a bare 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), or its `pending` while a retry waits when the fallback says the // picture failed. 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, pending = fallback, retries = IMAGE_RETRIES, ...img }: Omit, "src" | "srcSet" | "onError" | "referrerPolicy"> & { src: string | null | undefined; srcSet?: string; /** What stands in while there is no picture. */ fallback?: ReactNode; /** What stands in while a retry waits, when that is not the fallback: a fallback that says the * picture failed would say it of one that may still come. */ pending?: 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(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 (waiting && src) return <>{pending}; if (!src || spent) return <>{fallback}; return ( // eslint-disable-next-line no-restricted-syntax -- the one the rest of the app goes through setMiss({ src, attempt: attempt + 1, waiting: attempt < retries })} /> ); }