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:
@@ -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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user