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
|
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
||||||
// still pressed only once.
|
// still pressed only once.
|
||||||
import type { ReactNode } from "react";
|
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 { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
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).
|
// only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare).
|
||||||
const MAX_BANNER_RETRIES = 3;
|
const MAX_BANNER_RETRIES = 3;
|
||||||
const [bannerAttempt, setBannerAttempt] = useState(0);
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||||
<div className="relative px-4 sm:px-6 pt-3">
|
<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
|
// 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
|
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||||
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
// 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
|
// 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).
|
// browser's negative cache (googleusercontent accepts an extra query param).
|
||||||
key={bannerAttempt}
|
key={bannerAttempt}
|
||||||
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
|
src={bannerSrc}
|
||||||
alt=""
|
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={() => {
|
onError={() => {
|
||||||
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
|
|||||||
Reference in New Issue
Block a user