fix(channel): no broken-image glyph while the banner retries (E28)

The banner is retried on error; each failed attempt showed the browser's
broken-image glyph for as long as the retry waited. The <img> now stays
invisible until its source has actually loaded.
This commit is contained in:
2026-09-26 20:55:34 +02:00
parent 21df7c8348
commit 6653d81a80
2 changed files with 63 additions and 4 deletions
@@ -6,7 +6,7 @@
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
// still pressed only once.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { describe, it, expect, vi, beforeAll, afterEach, onTestFinished } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -97,3 +97,53 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
},
);
});
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
// next waits, a visible <img> shows the browser's broken-image glyph (seen live, E28). It stays
// invisible until a source has actually loaded.
describe("ChannelPage — the banner", () => {
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
onTestFinished(() => {
vi.useRealTimers();
});
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "LEMMiNO",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
banner_url: "https://yt3.example/banner",
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const { container } = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const banner = () =>
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]')!;
await waitFor(() => expect(banner()).toBeTruthy());
expect(banner().classList.contains("opacity-0")).toBe(true);
fireEvent.error(banner());
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
expect(banner().getAttribute("src")).toContain("?r=1");
expect(banner().classList.contains("opacity-0")).toBe(true);
fireEvent.load(banner());
expect(banner().classList.contains("opacity-0")).toBe(false);
});
});
+12 -3
View File
@@ -88,6 +88,9 @@ export default function ChannelPage({
// only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare).
const MAX_BANNER_RETRIES = 3;
const [bannerAttempt, setBannerAttempt] = useState(0);
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live, E28).
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
@@ -319,12 +322,15 @@ export default function ChannelPage({
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
const bannerSrc = ch?.banner_url
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
: null;
return (
<>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
@@ -336,9 +342,12 @@ export default function ChannelPage({
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
src={bannerSrc}
alt=""
className="w-full h-full object-cover object-center"
className={`w-full h-full object-cover object-center ${
bannerLoaded === bannerSrc ? "" : "opacity-0"
}`}
onLoad={() => setBannerLoaded(bannerSrc)}
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(