From 6653d81a80b2246dec8b3e7757263de0498f6262 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 26 Sep 2026 20:55:34 +0200 Subject: [PATCH] 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 now stays invisible until its source has actually loaded. --- .../ChannelPage.busyFocus.dom.test.tsx | 52 ++++++++++++++++++- frontend/src/components/ChannelPage.tsx | 15 ++++-- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx b/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx index 4882f202..18672182 100644 --- a/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx +++ b/frontend/src/components/ChannelPage.busyFocus.dom.test.tsx @@ -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 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( + + {}} + onShowInFeed={() => {}} + /> + , + ); + const banner = () => + container.querySelector('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); + }); +}); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 147e9efb..af605411 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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 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(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 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 ( <> {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
- {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 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(