authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-07-30 14:13:50-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 14:13:50-04:00
log1d77cec991971d3113918e896b1c871e8b044da7
tree99685a2dac59fd9b9f487e73c5df9c9bc5347e4e
parent4ba4871af76081f0e830a91b8f747ff2de53ba7d
signature Signed by PGP key B5690EEEBB952194

fix: cache project favicons across web and mobile (#4767)


9 files changed, 552 insertions(+), 48 deletions(-)

apps/mobile/src/components/ProjectFavicon.tsx+45-12
......@@ -1,14 +1,21 @@
11import { SymbolView } from "./AppSymbol";
22import { Image } from "expo-image";
3import { useState } from "react";
3import { useLayoutEffect, useMemo, useState } from "react";
44import { View } from "react-native";
55import type { EnvironmentId } from "@t3tools/contracts";
6import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
6import {
7 getProjectFaviconCacheKey,
8 isProjectFaviconFallbackUrl,
9} from "@t3tools/shared/projectFavicon";
710import { useThemeColor } from "../lib/useThemeColor";
811import { useAssetUrl } from "../state/assets";
9
10/* ─── Favicon cache (matches web pattern) ────────────────────────────── */
11const loadedFaviconUrls = new Set<string>();
12import {
13 beginProjectFaviconRequest,
14 createProjectFaviconRequest,
15 hasLoadedProjectFavicon,
16 markProjectFaviconFailed,
17 markProjectFaviconLoaded,
18} from "./projectFaviconCache";
1219
1320/* ─── Component ──────────────────────────────────────────────────────── */
1421export function ProjectFavicon(props: {
......@@ -26,10 +33,15 @@ export function ProjectFavicon(props: {
2633 : { _tag: "project-favicon", cwd: props.workspaceRoot },
2734 );
2835 const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
36 const cacheKey =
37 renderableFaviconUrl && props.workspaceRoot
38 ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
39 : null;
2940
3041 return (
3142 <ProjectFaviconImage
32 key={faviconUrl}
43 key={cacheKey}
44 cacheKey={cacheKey}
3345 faviconUrl={renderableFaviconUrl}
3446 open={props.open}
3547 projectTitle={props.projectTitle}
......@@ -39,18 +51,32 @@ export function ProjectFavicon(props: {
3951}
4052
4153function ProjectFaviconImage(props: {
54 readonly cacheKey: string | null;
4255 readonly faviconUrl: string | null;
4356 readonly open?: boolean;
4457 readonly projectTitle: string;
4558 readonly size: number;
4659}) {
4760 const iconMuted = useThemeColor("--color-icon-subtle");
61 const faviconRequest = useMemo(
62 () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl),
63 [props.cacheKey, props.faviconUrl],
64 );
65 const [activeFaviconRequest, setActiveFaviconRequest] = useState<typeof faviconRequest>(null);
66 useLayoutEffect(() => {
67 if (faviconRequest === null) return;
68
69 const endRequest = beginProjectFaviconRequest(faviconRequest);
70 setActiveFaviconRequest(faviconRequest);
71 return endRequest;
72 }, [faviconRequest]);
4873
4974 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
50 props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading",
75 hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading",
5176 );
5277
53 const showImage = props.faviconUrl !== null && status === "loaded";
78 const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
79 const showImage = requestIsActive && status === "loaded";
5480
5581 return (
5682 <View
......@@ -72,11 +98,15 @@ function ProjectFaviconImage(props: {
7298 ) : null}
7399
74100 {/* Favicon image (hidden until loaded) */}
75 {props.faviconUrl ? (
101 {requestIsActive ? (
76102 <Image
103 key={faviconRequest.faviconUrl}
77104 source={{
78 uri: props.faviconUrl,
105 uri: faviconRequest.faviconUrl,
106 cacheKey: faviconRequest.cacheKey,
79107 }}
108 cachePolicy="memory-disk"
109 recyclingKey={faviconRequest.cacheKey}
80110 accessibilityLabel={`${props.projectTitle} favicon`}
81111 style={{
82112 width: props.size,
......@@ -86,10 +116,13 @@ function ProjectFaviconImage(props: {
86116 }}
87117 contentFit="contain"
88118 onLoad={() => {
89 if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl);
119 if (!markProjectFaviconLoaded(faviconRequest)) return;
90120 setStatus("loaded");
91121 }}
92 onError={() => setStatus("error")}
122 onError={() => {
123 if (!markProjectFaviconFailed(faviconRequest)) return;
124 setStatus("error");
125 }}
93126 />
94127 ) : null}
95128 </View>
apps/mobile/src/components/projectFaviconCache.test.ts created+108
......@@ -0,0 +1,108 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 beginProjectFaviconRequest,
5 createProjectFaviconRequest,
6 hasLoadedProjectFavicon,
7 markProjectFaviconFailed,
8 markProjectFaviconLoaded,
9} from "./projectFaviconCache";
10
11describe("project favicon cache", () => {
12 it("ignores callbacks from a superseded URL", () => {
13 const cacheKey = "environment-1:/workspace:v1-favicon.svg";
14 const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg";
15 const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg";
16
17 const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl);
18 const endExpiredRequest = beginProjectFaviconRequest(expiredRequest);
19 markProjectFaviconLoaded(expiredRequest);
20 const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl);
21 const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest);
22
23 expect(markProjectFaviconLoaded(expiredRequest)).toBe(false);
24 expect(markProjectFaviconFailed(expiredRequest)).toBe(false);
25 expect(hasLoadedProjectFavicon(cacheKey)).toBe(true);
26 expect(markProjectFaviconFailed(refreshedRequest)).toBe(true);
27 expect(hasLoadedProjectFavicon(cacheKey)).toBe(false);
28
29 endRefreshedRequest();
30 endExpiredRequest();
31 });
32
33 it("evicts the URL that actually failed", () => {
34 const cacheKey = "environment-1:/workspace:v2-favicon.svg";
35 const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg";
36 const request = createProjectFaviconRequest(cacheKey, faviconUrl);
37 const endRequest = beginProjectFaviconRequest(request);
38
39 markProjectFaviconLoaded(request);
40
41 expect(markProjectFaviconFailed(request)).toBe(true);
42 expect(hasLoadedProjectFavicon(cacheKey)).toBe(false);
43
44 endRequest();
45 });
46
47 it("does not supersede a request until the next request begins", () => {
48 const cacheKey = "environment-1:/workspace:v3-favicon.svg";
49 const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg";
50 const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg";
51 const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl);
52 const endCommittedRequest = beginProjectFaviconRequest(committedRequest);
53
54 createProjectFaviconRequest(cacheKey, abandonedUrl);
55
56 expect(markProjectFaviconLoaded(committedRequest)).toBe(true);
57 expect(hasLoadedProjectFavicon(cacheKey)).toBe(true);
58
59 endCommittedRequest();
60 });
61
62 it("requires a cache key before creating a URL-bearing request", () => {
63 const firstUrl = "https://environment.example/api/assets/first/favicon.svg";
64 const secondUrl = "https://environment.example/api/assets/second/favicon.svg";
65
66 expect(createProjectFaviconRequest(null, firstUrl)).toBeNull();
67 expect(createProjectFaviconRequest(null, secondUrl)).toBeNull();
68 });
69
70 it("restores the remaining active URL when a newer request ends", () => {
71 const cacheKey = "environment-1:/workspace:v4-favicon.svg";
72 const firstRequest = createProjectFaviconRequest(
73 cacheKey,
74 "https://environment.example/api/assets/first/v4-favicon.svg",
75 );
76 const secondRequest = createProjectFaviconRequest(
77 cacheKey,
78 "https://environment.example/api/assets/second/v4-favicon.svg",
79 );
80 const endFirstRequest = beginProjectFaviconRequest(firstRequest);
81 const endSecondRequest = beginProjectFaviconRequest(secondRequest);
82
83 expect(markProjectFaviconLoaded(firstRequest)).toBe(false);
84 endSecondRequest();
85 expect(markProjectFaviconLoaded(firstRequest)).toBe(true);
86 endFirstRequest();
87 expect(markProjectFaviconLoaded(firstRequest)).toBe(false);
88 });
89
90 it("bounds remembered loaded revisions", () => {
91 const firstCacheKey = "environment-1:/workspace:revision-0";
92 let lastCacheKey = firstCacheKey;
93
94 for (let revision = 0; revision < 300; revision++) {
95 lastCacheKey = `environment-1:/workspace:revision-${revision}`;
96 const request = createProjectFaviconRequest(
97 lastCacheKey,
98 `https://environment.example/api/assets/revision-${revision}/favicon.svg`,
99 );
100 const endRequest = beginProjectFaviconRequest(request);
101 markProjectFaviconLoaded(request);
102 endRequest();
103 }
104
105 expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false);
106 expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true);
107 });
108});
apps/mobile/src/components/projectFaviconCache.ts created+94
......@@ -0,0 +1,94 @@
1export interface ProjectFaviconRequest {
2 readonly cacheKey: string;
3 readonly faviconUrl: string;
4}
5
6interface ActiveFaviconRequests {
7 readonly urls: Map<string, number>;
8 currentUrl: string;
9}
10
11const MAX_LOADED_FAVICONS = 256;
12const activeFaviconRequests = new Map<string, ActiveFaviconRequests>();
13const loadedFaviconKeys = new Map<string, true>();
14
15export function createProjectFaviconRequest(
16 cacheKey: string,
17 faviconUrl: string,
18): ProjectFaviconRequest;
19export function createProjectFaviconRequest(
20 cacheKey: string | null,
21 faviconUrl: string | null,
22): ProjectFaviconRequest | null;
23export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) {
24 if (!cacheKey || !faviconUrl) return null;
25 return { cacheKey, faviconUrl };
26}
27
28export function beginProjectFaviconRequest(request: ProjectFaviconRequest) {
29 let activeRequests = activeFaviconRequests.get(request.cacheKey);
30 if (!activeRequests) {
31 activeRequests = { currentUrl: request.faviconUrl, urls: new Map() };
32 activeFaviconRequests.set(request.cacheKey, activeRequests);
33 }
34
35 const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0;
36 activeRequests.urls.delete(request.faviconUrl);
37 activeRequests.urls.set(request.faviconUrl, activeCount + 1);
38 activeRequests.currentUrl = request.faviconUrl;
39
40 let ended = false;
41 return () => {
42 if (ended) return;
43 ended = true;
44
45 const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1;
46 if (remainingCount > 0) {
47 activeRequests.urls.set(request.faviconUrl, remainingCount);
48 return;
49 }
50
51 activeRequests.urls.delete(request.faviconUrl);
52 if (activeRequests.urls.size === 0) {
53 if (activeFaviconRequests.get(request.cacheKey) === activeRequests) {
54 activeFaviconRequests.delete(request.cacheKey);
55 }
56 return;
57 }
58
59 if (activeRequests.currentUrl === request.faviconUrl) {
60 activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!;
61 }
62 };
63}
64
65export function hasLoadedProjectFavicon(cacheKey: string | null) {
66 return cacheKey !== null && loadedFaviconKeys.has(cacheKey);
67}
68
69function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) {
70 return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl;
71}
72
73function rememberLoadedProjectFavicon(cacheKey: string) {
74 loadedFaviconKeys.delete(cacheKey);
75 loadedFaviconKeys.set(cacheKey, true);
76
77 if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) {
78 loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!);
79 }
80}
81
82export function markProjectFaviconLoaded(request: ProjectFaviconRequest) {
83 if (!isCurrentProjectFaviconRequest(request)) return false;
84
85 rememberLoadedProjectFavicon(request.cacheKey);
86 return true;
87}
88
89export function markProjectFaviconFailed(request: ProjectFaviconRequest) {
90 if (!isCurrentProjectFaviconRequest(request)) return false;
91
92 loadedFaviconKeys.delete(request.cacheKey);
93 return true;
94}
apps/server/src/assets/AssetAccess.test.ts+45-1
......@@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
22import { ThreadId } from "@t3tools/contracts";
33import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
44import { describe, expect, it } from "@effect/vitest";
5import * as Crypto from "effect/Crypto";
56import * as Effect from "effect/Effect";
67import * as FileSystem from "effect/FileSystem";
78import * as Layer from "effect/Layer";
89import * as Path from "effect/Path";
910import * as PlatformError from "effect/PlatformError";
11import * as TestClock from "effect/testing/TestClock";
1012
1113import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
1214import * as ServerConfig from "../config.ts";
......@@ -214,12 +216,21 @@ describe("AssetAccess", () => {
214216 prefix: "t3-asset-favicon-",
215217 });
216218 const faviconPath = path.join(root, "favicon.svg");
217 yield* fileSystem.writeFileString(faviconPath, "<svg />");
219 const initialFavicon = "<svg>a</svg>";
220 const updatedFavicon = "<svg>b</svg>";
221 expect(updatedFavicon).toHaveLength(initialFavicon.length);
222 yield* fileSystem.writeFileString(faviconPath, initialFavicon);
218223 const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath);
219224
220225 const faviconResult = yield* issueAssetUrl({
221226 resource: { _tag: "project-favicon", cwd: root },
222227 });
228 expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/);
229 expect(
230 yield* issueAssetUrl({
231 resource: { _tag: "project-favicon", cwd: root },
232 }),
233 ).toEqual(faviconResult);
223234 const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
224235 const faviconSeparatorIndex = faviconSuffix.indexOf("/");
225236 expect(
......@@ -229,6 +240,14 @@ describe("AssetAccess", () => {
229240 ),
230241 ).toEqual({ kind: "file", path: canonicalFaviconPath });
231242
243 yield* fileSystem.writeFileString(faviconPath, updatedFavicon);
244 const updatedFaviconResult = yield* issueAssetUrl({
245 resource: { _tag: "project-favicon", cwd: root },
246 });
247 expect(
248 updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")),
249 ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/")));
250
232251 yield* fileSystem.remove(faviconPath);
233252 const fallbackResult = yield* issueAssetUrl({
234253 resource: { _tag: "project-favicon", cwd: root },
......@@ -245,6 +264,31 @@ describe("AssetAccess", () => {
245264 }).pipe(Effect.provide(testLayer)),
246265 );
247266
267 it.effect("buckets project favicon expiry after content hashing", () =>
268 Effect.gen(function* () {
269 const crypto = yield* Crypto.Crypto;
270 const fileSystem = yield* FileSystem.FileSystem;
271 const path = yield* Path.Path;
272 const root = yield* fileSystem.makeTempDirectoryScoped({
273 prefix: "t3-asset-favicon-expiry-",
274 });
275 yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), "<svg />");
276
277 const bucketMs = 30 * 60 * 1000;
278 yield* TestClock.setTime(bucketMs - 1);
279 const crossingCrypto = Crypto.make({
280 randomBytes: (size) => new Uint8Array(size),
281 digest: (algorithm, data) =>
282 TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))),
283 });
284 const result = yield* issueAssetUrl({
285 resource: { _tag: "project-favicon", cwd: root },
286 }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto));
287
288 expect(result.expiresAt).toBe(3 * bucketMs);
289 }).pipe(Effect.provide(testLayer)),
290 );
291
248292 it.effect("preserves structured project favicon resolution causes", () =>
249293 Effect.gen(function* () {
250294 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+49-14
......@@ -21,7 +21,9 @@ import {
2121} from "@t3tools/shared/filePreview";
2222import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
2323import * as Clock from "effect/Clock";
24import * as Crypto from "effect/Crypto";
2425import * as Effect from "effect/Effect";
26import * as Encoding from "effect/Encoding";
2527import * as FileSystem from "effect/FileSystem";
2628import * as Option from "effect/Option";
2729import * as Path from "effect/Path";
......@@ -44,6 +46,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets";
4446
4547const SIGNING_SECRET_NAME = "asset-access-signing-key";
4648const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
49const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
50const PROJECT_FAVICON_VERSION_PREFIX = "v";
4751const PREVIEW_ASSET_EXTENSIONS = new Set([
4852 ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
4953 ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
......@@ -169,7 +173,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
169173 const fileSystem = yield* FileSystem.FileSystem;
170174 const path = yield* Path.Path;
171175 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
172 const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
176 let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS;
173177 let claims: AssetClaims;
174178 let fileName: string;
175179
......@@ -293,18 +297,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
293297 ),
294298 );
295299 const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null;
296 if (
297 relativePath &&
298 !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
299 Effect.mapError(
300 (cause) =>
301 new AssetProjectFaviconInspectionError({
302 resource: input.resource,
303 cause,
304 }),
305 ),
306 ))
307 ) {
300 const canonicalFaviconPath = relativePath
301 ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe(
302 Effect.mapError(
303 (cause) =>
304 new AssetProjectFaviconInspectionError({
305 resource: input.resource,
306 cause,
307 }),
308 ),
309 )
310 : null;
311 if (relativePath && !canonicalFaviconPath) {
308312 return yield* new AssetProjectFaviconNotFoundError({
309313 resource: input.resource,
310314 });
......@@ -324,7 +328,31 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
324328 relativePath,
325329 expiresAt,
326330 };
327 fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER;
331 if (relativePath && canonicalFaviconPath) {
332 const crypto = yield* Crypto.Crypto;
333 const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe(
334 Effect.mapError(
335 (cause) =>
336 new AssetProjectFaviconInspectionError({
337 resource: input.resource,
338 cause,
339 }),
340 ),
341 );
342 const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe(
343 Effect.map(Encoding.encodeHex),
344 Effect.mapError(
345 (cause) =>
346 new AssetProjectFaviconInspectionError({
347 resource: input.resource,
348 cause,
349 }),
350 ),
351 );
352 fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`;
353 } else {
354 fileName = PROJECT_FAVICON_FALLBACK_MARKER;
355 }
328356 break;
329357 }
330358 }
......@@ -339,6 +367,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
339367 }),
340368 ),
341369 );
370 if (claims.kind === "project-favicon") {
371 const issuedAt = yield* Clock.currentTimeMillis;
372 expiresAt =
373 (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) *
374 PROJECT_FAVICON_TOKEN_BUCKET_MS;
375 claims = { ...claims, expiresAt };
376 }
342377 const encodedPayload = base64UrlEncode(encodeAssetClaims(claims));
343378 const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`;
344379 return {
apps/web/src/components/ProjectFavicon.test.tsx created+128
......@@ -0,0 +1,128 @@
1import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import type { EnvironmentId } from "@t3tools/contracts";
4
5const testState = vi.hoisted(() => ({
6 faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg",
7}));
8
9const hooks = vi.hoisted(() => {
10 let cursor = 0;
11 let slots: unknown[] = [];
12 const nextIndex = () => cursor++;
13
14 return {
15 beginRender() {
16 cursor = 0;
17 },
18 reset() {
19 cursor = 0;
20 slots = [];
21 },
22 useMemoCache(size: number): unknown[] {
23 const index = nextIndex();
24 if (!slots[index]) {
25 slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel"));
26 }
27 return slots[index] as unknown[];
28 },
29 useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
30 const index = nextIndex();
31 if (index >= slots.length) {
32 slots[index] =
33 typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
34 }
35 const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
36 const previous = slots[index] as T;
37 slots[index] =
38 typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
39 };
40 return [slots[index] as T, setValue];
41 },
42 };
43});
44
45vi.mock("react", async (importOriginal) => {
46 const actual = await importOriginal<typeof import("react")>();
47 return {
48 ...actual,
49 useState: hooks.useState,
50 };
51});
52
53vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
54vi.mock("../assets/assetUrls", () => ({
55 useAssetUrl: () => testState.faviconUrl,
56}));
57
58import { ProjectFavicon } from "./ProjectFavicon";
59
60type ProjectFaviconImageProps = {
61 readonly cacheKey: string;
62 readonly src: string;
63 readonly className?: string | undefined;
64 readonly fallbackIcon: ComponentType<{ className?: string }>;
65};
66
67type ImageElement = ReactElement<{
68 readonly src: string;
69 readonly onLoad?: () => void;
70 readonly onError?: () => void;
71}>;
72
73type ProjectFaviconImageElement = ReactElement<{
74 readonly children: [ReactElement | null, ImageElement | null, ImageElement | null];
75}>;
76
77function resolveImageComponent(): {
78 readonly Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement;
79 readonly props: ProjectFaviconImageProps;
80} {
81 hooks.beginRender();
82 const element = ProjectFavicon({
83 environmentId: "environment-test" as EnvironmentId,
84 cwd: "/workspace-test",
85 }) as ReactElement<ProjectFaviconImageProps>;
86 hooks.reset();
87
88 return {
89 Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement,
90 props: element.props,
91 };
92}
93
94function renderImage(
95 Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement,
96 props: ProjectFaviconImageProps,
97): ProjectFaviconImageElement {
98 hooks.beginRender();
99 return Component(props);
100}
101
102describe("ProjectFavicon", () => {
103 beforeEach(() => {
104 hooks.reset();
105 });
106
107 it("falls back when the displayed favicon fails without discarding a valid older image early", () => {
108 const { Component, props } = resolveImageComponent();
109 const initialLoadingImage = renderImage(Component, props).props.children[2];
110 initialLoadingImage?.props.onLoad?.();
111
112 const refreshedProps = {
113 ...props,
114 src: "https://environment.test/api/assets/token-b/v1-20-favicon.svg",
115 };
116 const refreshing = renderImage(Component, refreshedProps).props.children;
117 expect(refreshing[1]?.props.src).toBe(props.src);
118 refreshing[2]?.props.onError?.();
119
120 const afterRefreshError = renderImage(Component, refreshedProps).props.children;
121 expect(afterRefreshError[1]?.props.src).toBe(props.src);
122 afterRefreshError[1]?.props.onError?.();
123
124 const afterDisplayedError = renderImage(Component, refreshedProps).props.children;
125 expect(afterDisplayedError[0]).not.toBeNull();
126 expect(afterDisplayedError[1]).toBeNull();
127 });
128});
apps/web/src/components/ProjectFavicon.tsx+41-20
......@@ -1,12 +1,15 @@
11import type { EnvironmentId } from "@t3tools/contracts";
2import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
2import {
3 getProjectFaviconCacheKey,
4 isProjectFaviconFallbackUrl,
5} from "@t3tools/shared/projectFavicon";
36import { FolderIcon } from "lucide-react";
47import type { ComponentType } from "react";
58import { useState } from "react";
69import { useAssetUrl } from "../assets/assetUrls";
710import { cn } from "~/lib/utils";
811
9const loadedProjectFaviconSrcs = new Set<string>();
12const loadedProjectFaviconSrcs = new Map<string, string>();
1013
1114export function ProjectFavicon(input: {
1215 environmentId: EnvironmentId;
......@@ -24,9 +27,12 @@ export function ProjectFavicon(input: {
2427 return <ProjectFaviconFallback className={input.className} icon={FallbackIcon} />;
2528 }
2629
30 const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src);
31
2732 return (
2833 <ProjectFaviconImage
29 key={src}
34 key={cacheKey}
35 cacheKey={cacheKey}
3036 src={src}
3137 className={input.className}
3238 fallbackIcon={FallbackIcon}
......@@ -45,37 +51,52 @@ function ProjectFaviconFallback({
4551}
4652
4753function ProjectFaviconImage({
54 cacheKey,
4855 src,
4956 className,
5057 fallbackIcon: FallbackIcon,
5158}: {
59 readonly cacheKey: string;
5260 readonly src: string;
5361 readonly className?: string | undefined;
5462 readonly fallbackIcon: ComponentType<{ className?: string }>;
5563}) {
56 const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
57 loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading",
64 const [displayedSrc, setDisplayedSrc] = useState<string | null>(
65 () => loadedProjectFaviconSrcs.get(cacheKey) ?? null,
5866 );
67 const isLoading = displayedSrc !== src;
68 const handleLoadError = (failedSrc: string) => {
69 if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) {
70 loadedProjectFaviconSrcs.delete(cacheKey);
71 }
72 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
73 };
5974
6075 return (
6176 <>
62 {status !== "loaded" ? (
77 {displayedSrc === null ? (
6378 <ProjectFaviconFallback className={className} icon={FallbackIcon} />
6479 ) : null}
65 <img
66 src={src}
67 alt=""
68 className={cn(
69 "size-3.5 shrink-0 rounded-sm object-contain",
70 status !== "loaded" && "hidden",
71 className,
72 )}
73 onLoad={() => {
74 loadedProjectFaviconSrcs.add(src);
75 setStatus("loaded");
76 }}
77 onError={() => setStatus("error")}
78 />
80 {displayedSrc ? (
81 <img
82 src={displayedSrc}
83 alt=""
84 className={cn("size-3.5 shrink-0 rounded-sm object-contain", className)}
85 onError={() => handleLoadError(displayedSrc)}
86 />
87 ) : null}
88 {isLoading ? (
89 <img
90 src={src}
91 alt=""
92 className="hidden"
93 onLoad={() => {
94 loadedProjectFaviconSrcs.set(cacheKey, src);
95 setDisplayedSrc(src);
96 }}
97 onError={() => handleLoadError(src)}
98 />
99 ) : null}
79100 </>
80101 );
81102}
packages/shared/src/projectFavicon.test.ts+25-1
......@@ -1,8 +1,32 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts";
3import {
4 getProjectFaviconCacheKey,
5 isProjectFaviconFallbackUrl,
6 PROJECT_FAVICON_FALLBACK_MARKER,
7} from "./projectFavicon.ts";
48
59describe("project favicon", () => {
10 it("uses the project and versioned filename as the cache identity", () => {
11 const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg";
12 const refreshedUrl =
13 "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg";
14
15 expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe(
16 getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl),
17 );
18 expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe(
19 getProjectFaviconCacheKey(
20 "environment-1",
21 "/workspace",
22 "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg",
23 ),
24 );
25 expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe(
26 getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl),
27 );
28 });
29
630 it("identifies fallback asset URLs by their dedicated filename", () => {
731 expect(
832 isProjectFaviconFallbackUrl(
packages/shared/src/projectFavicon.ts+17
......@@ -1,5 +1,22 @@
11export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";
22
3export function getProjectFaviconCacheKey(
4 environmentId: string,
5 workspaceRoot: string,
6 url: string,
7) {
8 let revision = url;
9
10 try {
11 const pathname = new URL(url, "https://t3.invalid").pathname;
12 revision = pathname.slice(pathname.lastIndexOf("/") + 1);
13 } catch {
14 // Keep the full value as a safe fallback for malformed URLs.
15 }
16
17 return JSON.stringify([environmentId, workspaceRoot, revision]);
18}
19
320export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean {
421 if (!url) return false;
522