Every one of them is the same shape: a value derived correctly, and a WRITE to the thing it derives from left unattended. Deriving hides a stale value; it does not consume it. PlayerModal — the watch toggle inherited the previous video's auto-watch. Moving the queue-advance sync into render was right for `currentVideoId`, but the same block also synced `status`, and that one had an ordering constraint of its own: useYtProgress's cleanup for the OUTGOING item can still call setWatched(true) near the end of a clip, and cleanups run after the commit. As an effect the sync landed last and corrected it; from render it lands first and the stray write wins. Press Next in the final ten seconds and the NEXT video showed as watched — one click on the toggle then sent onState(next, "new") for an item already new. Fixed by keying the mirror to the id it describes, so a write for another item is ignored rather than displayed, and no ordering has to be argued about at all. ChannelsProvider — a focus-channel intent was masked by the page rather than consumed, and nothing ever cleared it. The manager is a page chunk: it unmounts on every navigation, and its seed effect runs again on each mount, so returning to it re-applied a channel name from earlier in the session over whatever the user had typed since. The consumed marker now lives in the provider, keyed on the token — which also closes the case the old clear-on-leave effect never handled, opening a channel page (that does not change `page` at all). TagFilterChips + ChannelTags x2 — `open = openRaw && hidden > 0` hides the overflow popover when the lane stops overflowing, but floating-ui only reports a close for a real event on a mounted surface, so the raw flag stayed true and the menu came back by itself on the next re-fit — a window resize or the text-size slider is enough, with nobody near it. The flag is now reset in render against the same condition that hides it. Avatar + downloadRow — remembering a failed image BY URL fixed the recycled-row frame but never forgot it, so a transient 429 pinned a channel to its initial for the life of the instance, never re-requesting. The memory is now dropped once `src` moves off it, which keeps both properties. And playerModalHooks' last exhaustive-deps suppression claimed "nothing in the body reads a render value" while reading five. The invariant that is actually load-bearing is that each of them is a per-item snapshot — written down instead. All four fixes are mutation-checked: each new test was seen red, against the fixed line only, naming the real defect.
63 lines
2.6 KiB
TypeScript
63 lines
2.6 KiB
TypeScript
import { useState } from "react";
|
|
|
|
// Avatar image with a graceful fallback.
|
|
//
|
|
// Uses referrerPolicy="no-referrer" because Google's avatar CDNs
|
|
// (yt3.ggpht.com / lh3.googleusercontent.com) rate-limit bursts of
|
|
// referrer-bearing requests — when a feed page fires dozens of avatar loads at
|
|
// once, a random subset gets throttled (HTTP 429) and the browser shows its
|
|
// broken-image icon. Dropping the referrer makes those CDNs serve the images.
|
|
// If a load still fails we render a neutral placeholder (optionally an initial)
|
|
// instead of the broken-image icon.
|
|
export default function Avatar({
|
|
src,
|
|
alt = "",
|
|
fallback,
|
|
className = "",
|
|
}: {
|
|
src?: string | null;
|
|
alt?: string;
|
|
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, the Messages thread header — 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>
|
|
);
|
|
}
|