The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
98 lines
4.6 KiB
TypeScript
98 lines
4.6 KiB
TypeScript
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), 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<ImgHTMLAttributes<HTMLImageElement>, "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<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 (waiting && src) return <>{pending}</>;
|
|
if (!src || 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 })}
|
|
/>
|
|
);
|
|
}
|