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
+19 -47
View File
@@ -1,15 +1,9 @@
import { useState } from "react";
import RemoteImage from "./RemoteImage";
// Avatar image with a graceful fallback.
//
// Uses referrerPolicy="no-referrer" because Google's avatar CDNs
// (yt3.ggpht.com / lh3.googleusercontent.com) refuse referrer-bearing requests
// from our origin — yt3 refuses even a single load, not only a burst (measured
// 2026-09-26) — and the browser shows its broken-image icon. Dropping the
// referrer makes those CDNs serve the images. The channel banner, from the same
// CDN, does the same (ChannelPage).
// If a load still fails we render a neutral placeholder (optionally an initial)
// instead of the broken-image icon.
// Avatar image with a graceful fallback: a neutral disc (optionally the name's initial) while there is
// no picture. The loading itself — no Referer for Google's avatar CDNs, the retry past the browser's
// negative cache, the failure remembered by URL so a recycled feed slot never paints one channel's
// initial over the next one's picture — is ui/RemoteImage's.
export default function Avatar({
src,
alt = "",
@@ -21,43 +15,21 @@ export default function Avatar({
fallback?: string; // a name/title; its first letter is shown when there's no image
className?: string;
}) {
// The failure is remembered BY URL, not as a boolean that an effect resets when `src` changes.
// That distinction is visible: this component is recycled across items in the virtualized feed, so
// one avatar's 429 used to paint the fallback initial over the NEXT item's perfectly good image
// for a frame — React committed the render with the new `src` and the stale `failed`, and only the
// post-paint effect cleared it. Derived state cannot be stale, so there is nothing to reset.
//
// Forgetting the URL once `src` has moved off it is the other half, and it is what keeps the 429
// TRANSIENT: without it the memory outlives the reason for it, and a mounted instance whose `src`
// swaps — the player's channel avatar stepping through a queue — would
// show the initial for a throttled channel forever, never re-requesting even after the CDN
// relented. Written in render because it is convergent (the write falsifies its own condition) and
// because an effect would leave one committed frame in which the memory is stale, which is the
// frame this whole component is about.
const [failedSrc, setFailedSrc] = useState<string | null>(null);
if (failedSrc !== null && failedSrc !== src) setFailedSrc(null);
const failed = !!src && failedSrc === src;
if (src && !failed) {
return (
<img
src={src}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailedSrc(src)}
className={className}
/>
);
}
const initial = fallback?.trim()?.[0]?.toUpperCase() ?? "";
return (
<div
className={`bg-card grid place-items-center font-semibold text-muted select-none ${className}`}
aria-hidden
>
{initial}
</div>
<RemoteImage
src={src}
alt={alt}
loading="lazy"
className={className}
fallback={
<div
className={`bg-card grid place-items-center font-semibold text-muted select-none ${className}`}
aria-hidden
>
{initial}
</div>
}
/>
);
}