Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions apps/web/src/components/ProjectFavicon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

const hooks = vi.hoisted(() => {
let cursor = 0;
let slots: unknown[] = [];
const nextIndex = () => cursor++;

return {
beginRender() {
cursor = 0;
},
reset() {
cursor = 0;
slots = [];
},
useCallback<T>(callback: T): T {
nextIndex();
return callback;
},
useRef<T>(initialValue: T): { current: T } {
const index = nextIndex();
if (!slots[index]) {
slots[index] = { current: initialValue };
}
return slots[index] as { current: T };
},
useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
const index = nextIndex();
if (index >= slots.length) {
slots[index] =
typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
}
const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
const previous = slots[index] as T;
slots[index] =
typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
};
return [slots[index] as T, setValue];
},
};
});

vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useCallback: hooks.useCallback,
useRef: hooks.useRef,
useState: hooks.useState,
};
});

import { ProjectFaviconImage } from "./ProjectFavicon";

type FaviconChild = ReactElement<{
readonly className?: string;
readonly onClick?: () => void;
readonly onError?: () => void;
readonly onLoad?: () => void;
readonly src?: string;
}>;

function FallbackIcon(): null {
return null;
}

function renderImage(): ReadonlyArray<FaviconChild> {
hooks.beginRender();
const rendered = ProjectFaviconImage({
src: "/api/assets/token/favicon.svg",
fallbackIcon: FallbackIcon as ComponentType<{ className?: string }>,
}) as ReactElement<{ readonly children: ReadonlyArray<FaviconChild | null> }>;
return rendered.props.children.filter((child): child is FaviconChild => child !== null);
}

function visibleImage(children: ReadonlyArray<FaviconChild>): FaviconChild {
const result = children.find(
(child) => child.type === "img" && child.props.className !== "hidden",
);
if (!result) {
throw new Error("Expected visible favicon image");
}
return result;
}

function reloadCandidate(children: ReadonlyArray<FaviconChild>): FaviconChild {
const result = children.find(
(child) => child.type === "img" && child.props.className === "hidden",
);
if (!result) {
throw new Error("Expected favicon reload candidate");
}
return result;
}

function fallback(children: ReadonlyArray<FaviconChild>): FaviconChild | undefined {
return children.find((child) => child.type !== "img");
}

describe("ProjectFaviconImage", () => {
beforeEach(() => {
hooks.reset();
});

it("keeps a failed favicon reload clickable so it can be retried", () => {
const initialImage = visibleImage(renderImage());
initialImage.props.onError?.();

const failed = renderImage();
expect(fallback(failed)?.props.onClick).toBeTypeOf("function");

fallback(failed)?.props.onClick?.();
const retry = reloadCandidate(renderImage());
expect(retry.props.src).toBe("/api/assets/token/favicon.svg?faviconReload=1");
retry.props.onError?.();
expect(fallback(renderImage())?.props.onClick).toBeTypeOf("function");
});

it("ignores load and error events from superseded reloads", () => {
const initialImage = visibleImage(renderImage());
initialImage.props.onLoad?.();

const loadedImage = visibleImage(renderImage());
loadedImage.props.onClick?.();
const firstReload = reloadCandidate(renderImage());
visibleImage(renderImage()).props.onClick?.();
const secondReload = reloadCandidate(renderImage());

firstReload.props.onError?.();
expect(visibleImage(renderImage()).props.className).not.toContain("hidden");

secondReload.props.onError?.();
const retained = renderImage();
expect(visibleImage(retained).props.className).not.toContain("hidden");
expect(fallback(retained)).toBeUndefined();
visibleImage(retained).props.onClick?.();
const retry = reloadCandidate(renderImage());

secondReload.props.onLoad?.();
expect(visibleImage(renderImage()).props.className).not.toContain("hidden");

retry.props.onLoad?.();
expect(visibleImage(renderImage()).props.className).not.toContain("hidden");
});
});
83 changes: 76 additions & 7 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
import {
isProjectFaviconFallbackUrl,
withProjectFaviconReloadParam,
} from "@t3tools/shared/projectFavicon";
import { FolderIcon } from "lucide-react";
import type { ComponentType } from "react";
import { useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useAssetUrl } from "../assets/assetUrls";

const loadedProjectFaviconSrcs = new Set<string>();
Expand Down Expand Up @@ -36,14 +39,26 @@ export function ProjectFavicon(input: {
function ProjectFaviconFallback({
className,
icon: Icon,
onClick,
}: {
readonly className?: string | undefined;
readonly icon: ComponentType<{ className?: string }>;
readonly onClick?: (() => void) | undefined;
}) {
return <Icon className={`size-3.5 shrink-0 text-muted-foreground/50 ${className ?? ""}`} />;
const icon = <Icon className={`size-3.5 shrink-0 text-muted-foreground/50 ${className ?? ""}`} />;

if (!onClick) {
return icon;
}

return (
<span className="inline-flex shrink-0 cursor-pointer" onClick={onClick}>
{icon}
</span>
);
}

function ProjectFaviconImage({
export function ProjectFaviconImage({
src,
className,
fallbackIcon: FallbackIcon,
Expand All @@ -55,22 +70,76 @@ function ProjectFaviconImage({
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
);
const [visibleSrc, setVisibleSrc] = useState(src);
// Bumping the nonce re-fetches the favicon past the browser cache. The click
// is not consumed here, so it still bubbles to the row (which opens the
// thread) — pressing the icon both opens the thread and refreshes the icon.
const [reloadNonce, setReloadNonce] = useState(0);
const activeReloadNonce = useRef(0);
const requestReload = useCallback(() => {
const nextNonce = activeReloadNonce.current + 1;
activeReloadNonce.current = nextNonce;
setReloadNonce(nextNonce);
}, []);

// Preload each refresh separately while the current image remains visible.
// Keying the candidate by URL also detaches handlers from superseded
// requests, while the nonce guard covers an event already queued by React.
const pendingSrc = reloadNonce > 0 ? withProjectFaviconReloadParam(src, reloadNonce) : null;

return (
Comment thread
colonelpanic8 marked this conversation as resolved.
<>
{status !== "loaded" ? (
<ProjectFaviconFallback className={className} icon={FallbackIcon} />
<ProjectFaviconFallback
className={className}
icon={FallbackIcon}
onClick={status === "error" ? requestReload : undefined}
/>
) : null}
<img
src={src}
key={visibleSrc}
src={visibleSrc}
alt=""
onClick={requestReload}
Comment thread
colonelpanic8 marked this conversation as resolved.
Comment thread
colonelpanic8 marked this conversation as resolved.
className={`size-3.5 shrink-0 rounded-sm object-contain ${status === "loaded" ? "" : "hidden"} ${className ?? ""}`}
onLoad={() => {
if (activeReloadNonce.current !== reloadNonce) {
return;
}
loadedProjectFaviconSrcs.add(src);
setStatus("loaded");
}}
onError={() => setStatus("error")}
onError={() => {
if (activeReloadNonce.current !== reloadNonce) {
return;
}
setStatus("error");
}}
/>
{pendingSrc && pendingSrc !== visibleSrc ? (
<img
key={pendingSrc}
src={pendingSrc}
alt=""
className="hidden"
onLoad={() => {
if (activeReloadNonce.current !== reloadNonce) {
return;
}
loadedProjectFaviconSrcs.add(src);
setVisibleSrc(pendingSrc);
setStatus("loaded");
}}
onError={() => {
if (activeReloadNonce.current !== reloadNonce) {
return;
}
// A refresh failure must not replace a favicon that is already on
// screen. Initial-load retries stay on the clickable fallback.
setStatus((current) => (current === "loaded" ? current : "error"));
}}
/>
) : null}
</>
);
}
26 changes: 25 additions & 1 deletion packages/shared/src/projectFavicon.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vite-plus/test";

import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts";
import {
isProjectFaviconFallbackUrl,
PROJECT_FAVICON_FALLBACK_MARKER,
withProjectFaviconReloadParam,
} from "./projectFavicon.ts";

describe("project favicon", () => {
it("identifies fallback asset URLs by their dedicated filename", () => {
Expand All @@ -26,3 +30,23 @@ describe("project favicon", () => {
expect(isProjectFaviconFallbackUrl(null)).toBe(false);
});
});

describe("withProjectFaviconReloadParam", () => {
it("leaves the URL untouched until a reload is requested", () => {
expect(withProjectFaviconReloadParam("/api/assets/token/favicon.svg", 0)).toBe(
"/api/assets/token/favicon.svg",
);
expect(withProjectFaviconReloadParam("/api/assets/token/favicon.svg", -1)).toBe(
"/api/assets/token/favicon.svg",
);
});

it("appends a cache-busting parameter with the right separator", () => {
expect(withProjectFaviconReloadParam("/api/assets/token/favicon.svg", 2)).toBe(
"/api/assets/token/favicon.svg?faviconReload=2",
);
expect(withProjectFaviconReloadParam("/api/assets/token/favicon.svg?name=x", 3)).toBe(
"/api/assets/token/favicon.svg?name=x&faviconReload=3",
);
});
});
18 changes: 18 additions & 0 deletions packages/shared/src/projectFavicon.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";

const PROJECT_FAVICON_RELOAD_PARAM = "faviconReload";

/**
* Append a cache-busting query parameter so the browser re-fetches a project
* favicon whose URL is otherwise stable (the asset URL is derived from the
* project path, so an edited favicon keeps the same URL and stays cached).
*
* `nonce <= 0` returns the URL untouched so the first render uses the plain,
* shared URL and stays cache-friendly until a reload is explicitly requested.
*/
export function withProjectFaviconReloadParam(url: string, nonce: number): string {
if (!Number.isFinite(nonce) || nonce <= 0) {
return url;
}
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}${PROJECT_FAVICON_RELOAD_PARAM}=${nonce}`;
}

export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean {
if (!url) return false;

Expand Down
Loading