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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user