authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-05 16:15:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 16:15:32-07:00
log748fe0f8b68b7951df6797d4c22d2ca01314a3dc
treec2778dafb04d24f7a4ae4284ea7335d218fd3be5
parent88fc41c1b90ff21a7d30d69e0ebffbc0c3511b9c
signature Signed by PGP key B5690EEEBB952194

refactor(web): test favicon fallback through the component (#10220)


2 files changed, 38 insertions(+), 53 deletions(-)

apps/web/src/components/preview/PreviewFaviconIcon.test.tsx+37-45
...@@ -1,51 +1,43 @@...@@ -1,51 +1,43 @@
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";1import { act } from "react";
2import { renderToStaticMarkup } from "react-dom/server";2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { describe, expect, it, vi } from "vite-plus/test";3import { afterEach, expect, it, vi } from "vite-plus/test";
44
5const mocks = vi.hoisted(() => ({ favicon: null as string | null }));5vi.mock("~/browserFaviconStore", () => ({ useFaviconForThreadUrl: () => null }));
66
7vi.mock("~/browserFaviconStore", () => ({7import { FaviconImage } from "./PreviewFaviconIcon";
8 useFaviconForThreadUrl: () => mocks.favicon,8
9}));9let renderer: ReactTestRenderer | undefined;
1010
11import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon";11afterEach(async () => {
1212 await act(async () => renderer?.unmount());
13const threadRef = {13 vi.unstubAllGlobals();
14 environmentId: EnvironmentId.make("env-1"),14});
15 threadId: ThreadId.make("thread-1"),15
16};16it("falls through failed favicon sources and retries when the source list changes", async () => {
1717 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
18describe("preview favicon image", () => {18 const captured = "data:image/png;base64,AAAA";
19 it("renders a captured source before later fallback sources", () => {19 const remote = "https://public.example/icon";
20 expect(20 await act(async () => {
21 renderToStaticMarkup(21 renderer = create(
22 <FaviconImage22 <FaviconImage sources={[captured, remote]} fallback={<span>fallback</span>} />,
23 sources={["data:image/png;base64,AAAA", "https://public.example/icon"]}
24 fallback={<span>fallback</span>}
25 />,
26 ),
27 ).toContain('src="data:image/png;base64,AAAA"');
28 const captured = "data:image/png;base64,AAAA";
29 const google = "https://public.example/icon";
30 expect(selectFaviconSource([captured, google], new Set())).toBe(captured);
31 expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google);
32 expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull();
33 expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe(
34 "data:image/png;base64,BBBB",
35 );23 );
36 });24 });
25 expect(renderer!.root.findByType("img").props.src).toBe(captured);
3726
38 it("uses a stored project icon or falls back to the browser mockup", () => {27 await act(async () => renderer!.root.findByType("img").props.onError());
39 mocks.favicon = null;28 expect(renderer!.root.findByType("img").props.src).toBe(remote);
40 const html = renderToStaticMarkup(29
41 <PreviewFaviconIcon threadRef={threadRef} url="http://localhost:3000/" />,30 await act(async () => renderer!.root.findByType("img").props.onError());
42 );31 expect(renderer!.root.findAllByType("img")).toHaveLength(0);
43 expect(html).not.toContain("<img");32 expect(renderer!.root.findByType("span").children).toEqual(["fallback"]);
44 expect(html).toContain("rounded-[5px]");33
45 mocks.favicon = "data:image/png;base64,AAAA";34 await act(async () => {
46 const faviconHtml = renderToStaticMarkup(35 renderer!.update(
47 <PreviewFaviconIcon threadRef={threadRef} url="http://localhost:3000/" />,36 <FaviconImage
37 sources={[captured, "https://public.example/new-icon"]}
38 fallback={<span>fallback</span>}
39 />,
48 );40 );
49 expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"');
50 });41 });
42 expect(renderer!.root.findByType("img").props.src).toBe(captured);
51});43});
apps/web/src/components/preview/PreviewFaviconIcon.tsx+1-8
...@@ -6,13 +6,6 @@ import { cn } from "~/lib/utils";...@@ -6,13 +6,6 @@ import { cn } from "~/lib/utils";
66
7import { BrowserMockup } from "./BrowserMockup";7import { BrowserMockup } from "./BrowserMockup";
88
9export function selectFaviconSource(
10 sources: ReadonlyArray<string>,
11 failed: ReadonlySet<string>,
12): string | null {
13 return sources.find((candidate) => !failed.has(candidate)) ?? null;
14}
15
16export function FaviconImage(props: {9export function FaviconImage(props: {
17 sources: ReadonlyArray<string | null | undefined>;10 sources: ReadonlyArray<string | null | undefined>;
18 fallback: ReactNode;11 fallback: ReactNode;
...@@ -35,7 +28,7 @@ function FaviconImageAttempt(props: {...@@ -35,7 +28,7 @@ function FaviconImageAttempt(props: {
35 className?: string | undefined;28 className?: string | undefined;
36}) {29}) {
37 const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set());30 const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set());
38 const source = selectFaviconSource(props.sources, failed);31 const source = props.sources.find((candidate) => !failed.has(candidate));
39 if (!source) return props.fallback;32 if (!source) return props.fallback;
40 return (33 return (
41 <img34 <img