From 8bcd7c26be5293ebfbb84bd17594638d512ebefe Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:37:07 -0400 Subject: [PATCH 1/7] fix: cache project favicons across web and mobile - Version favicon asset URLs when the file changes - Reuse stable project-scoped cache keys across signed URLs - Configure mobile image caching and add coverage --- apps/mobile/src/components/ProjectFavicon.tsx | 22 ++++++-- apps/server/src/assets/AssetAccess.test.ts | 14 +++++ apps/server/src/assets/AssetAccess.ts | 50 ++++++++++++----- apps/web/src/components/ProjectFavicon.tsx | 53 ++++++++++++------- packages/shared/src/projectFavicon.test.ts | 26 ++++++++- packages/shared/src/projectFavicon.ts | 17 ++++++ 6 files changed, 142 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 772d5e8cc14..b5b662a7557 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -3,12 +3,15 @@ import { Image } from "expo-image"; import { useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ -const loadedFaviconUrls = new Set(); +const loadedFaviconKeys = new Set(); /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -26,10 +29,15 @@ export function ProjectFavicon(props: { : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + const cacheKey = + renderableFaviconUrl && props.workspaceRoot + ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + : null; return ( (() => - props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", + props.cacheKey && loadedFaviconKeys.has(props.cacheKey) ? "loaded" : "loading", ); const showImage = props.faviconUrl !== null && status === "loaded"; @@ -76,7 +85,10 @@ function ProjectFaviconImage(props: { { - if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); + if (props.cacheKey) loadedFaviconKeys.add(props.cacheKey); setStatus("loaded"); }} onError={() => setStatus("error")} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 42fd3f900e5..33498b874f3 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -220,6 +220,12 @@ describe("AssetAccess", () => { const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); + expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-z]+-[0-9a-z]+-favicon\.svg$/); + expect( + yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }), + ).toEqual(faviconResult); const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); expect( @@ -229,6 +235,14 @@ describe("AssetAccess", () => { ), ).toEqual({ kind: "file", path: canonicalFaviconPath }); + yield* fileSystem.writeFileString(faviconPath, "updated favicon"); + const updatedFaviconResult = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + expect( + updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")), + ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/"))); + yield* fileSystem.remove(faviconPath); const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..2ec84df00fe 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -44,6 +44,8 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; +const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; +const PROJECT_FAVICON_VERSION_PREFIX = "v"; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -169,7 +171,12 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + const issuedAt = yield* Clock.currentTimeMillis; + const expiresAt = + input.resource._tag === "project-favicon" + ? (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * + PROJECT_FAVICON_TOKEN_BUCKET_MS + : issuedAt + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; @@ -293,18 +300,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if ( - relativePath && - !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - )) - ) { + const canonicalFaviconPath = relativePath + ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ) + : null; + if (relativePath && !canonicalFaviconPath) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); @@ -324,7 +331,22 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; + if (relativePath && canonicalFaviconPath) { + const faviconInfo = yield* fileSystem.stat(canonicalFaviconPath).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + const modifiedAt = Option.getOrUndefined(faviconInfo.mtime)?.getTime() ?? 0; + const revision = `${modifiedAt.toString(36)}-${faviconInfo.size.toString(36)}`; + fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; + } else { + fileName = PROJECT_FAVICON_FALLBACK_MARKER; + } break; } } diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 201241731fa..86b50630097 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,12 +1,15 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Set(); +const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; @@ -24,9 +27,12 @@ export function ProjectFavicon(input: { return ; } + const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + return ( ; }) { - const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + const [displayedSrc, setDisplayedSrc] = useState( + () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, ); + const isLoading = displayedSrc !== src; return ( <> - {status !== "loaded" ? ( + {displayedSrc === null ? ( ) : null} - { - loadedProjectFaviconSrcs.add(src); - setStatus("loaded"); - }} - onError={() => setStatus("error")} - /> + {displayedSrc ? ( + + ) : null} + {isLoading ? ( + { + loadedProjectFaviconSrcs.set(cacheKey, src); + setDisplayedSrc(src); + }} + /> + ) : null} ); } diff --git a/packages/shared/src/projectFavicon.test.ts b/packages/shared/src/projectFavicon.test.ts index 0011b2fc7c9..1df17cc7fe5 100644 --- a/packages/shared/src/projectFavicon.test.ts +++ b/packages/shared/src/projectFavicon.test.ts @@ -1,8 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; -import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, + PROJECT_FAVICON_FALLBACK_MARKER, +} from "./projectFavicon.ts"; describe("project favicon", () => { + it("uses the project and versioned filename as the cache identity", () => { + const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg"; + const refreshedUrl = + "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg"; + + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe( + getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl), + ); + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( + getProjectFaviconCacheKey( + "environment-1", + "/workspace", + "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg", + ), + ); + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( + getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl), + ); + }); + it("identifies fallback asset URLs by their dedicated filename", () => { expect( isProjectFaviconFallbackUrl( diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index 2e46429b6c1..342b50a4b04 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,22 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconCacheKey( + environmentId: string, + workspaceRoot: string, + url: string, +): string { + let revision = url; + + try { + const pathname = new URL(url, "https://t3.invalid").pathname; + revision = pathname.slice(pathname.lastIndexOf("/") + 1); + } catch { + // Keep the full value as a safe fallback for malformed URLs. + } + + return JSON.stringify([environmentId, workspaceRoot, revision]); +} + export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { if (!url) return false; From f77304e476e60c732932c3a26c32291238cf33e3 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:15:26 -0400 Subject: [PATCH 2/7] fix: restore favicon load error fallback --- .../src/components/ProjectFavicon.test.tsx | 128 ++++++++++++++++++ apps/web/src/components/ProjectFavicon.tsx | 8 ++ 2 files changed, 136 insertions(+) create mode 100644 apps/web/src/components/ProjectFavicon.test.tsx diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx new file mode 100644 index 00000000000..c2fac8beb7e --- /dev/null +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -0,0 +1,128 @@ +import type { ComponentType, Dispatch, ReactElement, SetStateAction } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { EnvironmentId } from "@t3tools/contracts"; + +const testState = vi.hoisted(() => ({ + faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", +})); + +const hooks = vi.hoisted(() => { + let cursor = 0; + let slots: unknown[] = []; + const nextIndex = () => cursor++; + + return { + beginRender() { + cursor = 0; + }, + reset() { + cursor = 0; + slots = []; + }, + useMemoCache(size: number): unknown[] { + const index = nextIndex(); + if (!slots[index]) { + slots[index] = Array.from({ length: size }, () => Symbol.for("react.memo_cache_sentinel")); + } + return slots[index] as unknown[]; + }, + useState(initialValue: T | (() => T)): [T, Dispatch>] { + const index = nextIndex(); + if (index >= slots.length) { + slots[index] = + typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + const setValue: Dispatch> = (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(); + return { + ...actual, + useState: hooks.useState, + }; +}); + +vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); +vi.mock("../assets/assetUrls", () => ({ + useAssetUrl: () => testState.faviconUrl, +})); + +import { ProjectFavicon } from "./ProjectFavicon"; + +type ProjectFaviconImageProps = { + readonly cacheKey: string; + readonly src: string; + readonly className?: string | undefined; + readonly fallbackIcon: ComponentType<{ className?: string }>; +}; + +type ImageElement = ReactElement<{ + readonly src: string; + readonly onLoad?: () => void; + readonly onError?: () => void; +}>; + +type ProjectFaviconImageElement = ReactElement<{ + readonly children: [ReactElement | null, ImageElement | null, ImageElement | null]; +}>; + +function resolveImageComponent(): { + readonly Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement; + readonly props: ProjectFaviconImageProps; +} { + hooks.beginRender(); + const element = ProjectFavicon({ + environmentId: "environment-test" as EnvironmentId, + cwd: "/workspace-test", + }) as ReactElement; + hooks.reset(); + + return { + Component: element.type as (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, + props: element.props, + }; +} + +function renderImage( + Component: (props: ProjectFaviconImageProps) => ProjectFaviconImageElement, + props: ProjectFaviconImageProps, +): ProjectFaviconImageElement { + hooks.beginRender(); + return Component(props); +} + +describe("ProjectFavicon", () => { + beforeEach(() => { + hooks.reset(); + }); + + it("falls back when the displayed favicon fails without discarding a valid older image early", () => { + const { Component, props } = resolveImageComponent(); + const initialLoadingImage = renderImage(Component, props).props.children[2]; + initialLoadingImage?.props.onLoad?.(); + + const refreshedProps = { + ...props, + src: "https://environment.test/api/assets/token-b/v1-20-favicon.svg", + }; + const refreshing = renderImage(Component, refreshedProps).props.children; + expect(refreshing[1]?.props.src).toBe(props.src); + refreshing[2]?.props.onError?.(); + + const afterRefreshError = renderImage(Component, refreshedProps).props.children; + expect(afterRefreshError[1]?.props.src).toBe(props.src); + afterRefreshError[1]?.props.onError?.(); + + const afterDisplayedError = renderImage(Component, refreshedProps).props.children; + expect(afterDisplayedError[0]).not.toBeNull(); + expect(afterDisplayedError[1]).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 86b50630097..1df19a64075 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -65,6 +65,12 @@ function ProjectFaviconImage({ () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, ); const isLoading = displayedSrc !== src; + const handleLoadError = (failedSrc: string) => { + if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { + loadedProjectFaviconSrcs.delete(cacheKey); + } + setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); + }; return ( <> @@ -76,6 +82,7 @@ function ProjectFaviconImage({ src={displayedSrc} alt="" className={cn("size-3.5 shrink-0 rounded-sm object-contain", className)} + onError={() => handleLoadError(displayedSrc)} /> ) : null} {isLoading ? ( @@ -87,6 +94,7 @@ function ProjectFaviconImage({ loadedProjectFaviconSrcs.set(cacheKey, src); setDisplayedSrc(src); }} + onError={() => handleLoadError(src)} /> ) : null} From 275cf655ff87fcef783b0c69707578767f26d36f Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 02:51:57 -0400 Subject: [PATCH 3/7] fix: harden favicon cache invalidation --- apps/mobile/src/components/ProjectFavicon.tsx | 5 ++++- apps/server/src/assets/AssetAccess.test.ts | 9 ++++++--- apps/server/src/assets/AssetAccess.ts | 17 ++++++++++++++--- packages/shared/src/projectFavicon.ts | 2 +- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index b5b662a7557..c5808d8ac87 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -101,7 +101,10 @@ function ProjectFaviconImage(props: { if (props.cacheKey) loadedFaviconKeys.add(props.cacheKey); setStatus("loaded"); }} - onError={() => setStatus("error")} + onError={() => { + if (props.cacheKey) loadedFaviconKeys.delete(props.cacheKey); + setStatus("error"); + }} /> ) : null} diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 33498b874f3..fecf18a562d 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -214,13 +214,16 @@ describe("AssetAccess", () => { prefix: "t3-asset-favicon-", }); const faviconPath = path.join(root, "favicon.svg"); - yield* fileSystem.writeFileString(faviconPath, ""); + const initialFavicon = "a"; + const updatedFavicon = "b"; + expect(updatedFavicon).toHaveLength(initialFavicon.length); + yield* fileSystem.writeFileString(faviconPath, initialFavicon); const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); - expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-z]+-[0-9a-z]+-favicon\.svg$/); + expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); expect( yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, @@ -235,7 +238,7 @@ describe("AssetAccess", () => { ), ).toEqual({ kind: "file", path: canonicalFaviconPath }); - yield* fileSystem.writeFileString(faviconPath, "updated favicon"); + yield* fileSystem.writeFileString(faviconPath, updatedFavicon); const updatedFaviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 2ec84df00fe..b8b5a4b76e8 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,7 +21,9 @@ import { } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -168,6 +170,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i readonly resource: AssetResource; readonly workspaceRoot?: string; }) { + const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -332,7 +335,17 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i expiresAt, }; if (relativePath && canonicalFaviconPath) { - const faviconInfo = yield* fileSystem.stat(canonicalFaviconPath).pipe( + const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + ); + const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe( + Effect.map(Encoding.encodeHex), Effect.mapError( (cause) => new AssetProjectFaviconInspectionError({ @@ -341,8 +354,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const modifiedAt = Option.getOrUndefined(faviconInfo.mtime)?.getTime() ?? 0; - const revision = `${modifiedAt.toString(36)}-${faviconInfo.size.toString(36)}`; fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; } else { fileName = PROJECT_FAVICON_FALLBACK_MARKER; diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index 342b50a4b04..eebc1a8a1b6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -4,7 +4,7 @@ export function getProjectFaviconCacheKey( environmentId: string, workspaceRoot: string, url: string, -): string { +) { let revision = url; try { From fb71493cf877e0fae0476e7878f7ba604f21fa63 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:05:00 -0400 Subject: [PATCH 4/7] fix: ignore stale favicon load errors --- apps/mobile/src/components/ProjectFavicon.tsx | 15 +++++---- .../components/projectFaviconCache.test.ts | 31 +++++++++++++++++++ .../src/components/projectFaviconCache.ts | 19 ++++++++++++ apps/server/src/assets/AssetAccess.ts | 2 +- 4 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/components/projectFaviconCache.test.ts create mode 100644 apps/mobile/src/components/projectFaviconCache.ts diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c5808d8ac87..5f5c53999f3 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -9,9 +9,11 @@ import { } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; - -/* ─── Favicon cache (matches web pattern) ────────────────────────────── */ -const loadedFaviconKeys = new Set(); +import { + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -56,7 +58,7 @@ function ProjectFaviconImage(props: { const iconMuted = useThemeColor("--color-icon-subtle"); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - props.cacheKey && loadedFaviconKeys.has(props.cacheKey) ? "loaded" : "loading", + hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", ); const showImage = props.faviconUrl !== null && status === "loaded"; @@ -83,6 +85,7 @@ function ProjectFaviconImage(props: { {/* Favicon image (hidden until loaded) */} {props.faviconUrl ? ( { - if (props.cacheKey) loadedFaviconKeys.add(props.cacheKey); + markProjectFaviconLoaded(props.cacheKey, props.faviconUrl); setStatus("loaded"); }} onError={() => { - if (props.cacheKey) loadedFaviconKeys.delete(props.cacheKey); + if (!markProjectFaviconFailed(props.cacheKey, props.faviconUrl)) return; setStatus("error"); }} /> diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/components/projectFaviconCache.test.ts new file mode 100644 index 00000000000..45674989490 --- /dev/null +++ b/apps/mobile/src/components/projectFaviconCache.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + hasLoadedProjectFavicon, + markProjectFaviconFailed, + markProjectFaviconLoaded, +} from "./projectFaviconCache"; + +describe("project favicon cache", () => { + it("ignores an old URL error after a refreshed URL loads", () => { + const cacheKey = "environment-1:/workspace:v1-favicon.svg"; + const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; + const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; + + markProjectFaviconLoaded(cacheKey, expiredUrl); + markProjectFaviconLoaded(cacheKey, refreshedUrl); + + expect(markProjectFaviconFailed(cacheKey, expiredUrl)).toBe(false); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + }); + + it("evicts the URL that actually failed", () => { + const cacheKey = "environment-1:/workspace:v2-favicon.svg"; + const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; + + markProjectFaviconLoaded(cacheKey, faviconUrl); + + expect(markProjectFaviconFailed(cacheKey, faviconUrl)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + }); +}); diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/components/projectFaviconCache.ts new file mode 100644 index 00000000000..90a41fec050 --- /dev/null +++ b/apps/mobile/src/components/projectFaviconCache.ts @@ -0,0 +1,19 @@ +const loadedFaviconUrls = new Map(); + +export function hasLoadedProjectFavicon(cacheKey: string | null) { + return cacheKey !== null && loadedFaviconUrls.has(cacheKey); +} + +export function markProjectFaviconLoaded(cacheKey: string | null, faviconUrl: string | null) { + if (cacheKey && faviconUrl) loadedFaviconUrls.set(cacheKey, faviconUrl); +} + +export function markProjectFaviconFailed(cacheKey: string | null, faviconUrl: string | null) { + if (!cacheKey) return true; + + const loadedUrl = loadedFaviconUrls.get(cacheKey); + if (loadedUrl !== undefined && loadedUrl !== faviconUrl) return false; + + loadedFaviconUrls.delete(cacheKey); + return true; +} diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b8b5a4b76e8..f69aa30fef5 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -170,7 +170,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i readonly resource: AssetResource; readonly workspaceRoot?: string; }) { - const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; @@ -335,6 +334,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i expiresAt, }; if (relativePath && canonicalFaviconPath) { + const crypto = yield* Crypto.Crypto; const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( Effect.mapError( (cause) => From 6ca94bfd8bad0fab7c8abf9a04c972be8ef1dcfe Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:16:28 -0400 Subject: [PATCH 5/7] fix: make favicon refresh ordering deterministic --- apps/mobile/src/components/ProjectFavicon.tsx | 17 +++++++-- .../components/projectFaviconCache.test.ts | 18 ++++++---- .../src/components/projectFaviconCache.ts | 35 +++++++++++++------ apps/server/src/assets/AssetAccess.test.ts | 27 ++++++++++++++ apps/server/src/assets/AssetAccess.ts | 14 ++++---- 5 files changed, 86 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 5f5c53999f3..28820698001 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,6 +1,6 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { @@ -10,9 +10,11 @@ import { import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; import { + beginProjectFaviconRequest, hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, + type ProjectFaviconRequest, } from "./projectFaviconCache"; /* ─── Component ──────────────────────────────────────────────────────── */ @@ -56,6 +58,15 @@ function ProjectFaviconImage(props: { readonly size: number; }) { const iconMuted = useThemeColor("--color-icon-subtle"); + const faviconRequestRef = useRef(null); + if ( + faviconRequestRef.current === null || + faviconRequestRef.current.cacheKey !== props.cacheKey || + faviconRequestRef.current.faviconUrl !== props.faviconUrl + ) { + faviconRequestRef.current = beginProjectFaviconRequest(props.cacheKey, props.faviconUrl); + } + const faviconRequest = faviconRequestRef.current; const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", @@ -101,11 +112,11 @@ function ProjectFaviconImage(props: { }} contentFit="contain" onLoad={() => { - markProjectFaviconLoaded(props.cacheKey, props.faviconUrl); + if (!markProjectFaviconLoaded(faviconRequest)) return; setStatus("loaded"); }} onError={() => { - if (!markProjectFaviconFailed(props.cacheKey, props.faviconUrl)) return; + if (!markProjectFaviconFailed(faviconRequest)) return; setStatus("error"); }} /> diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/components/projectFaviconCache.test.ts index 45674989490..03947f0bc52 100644 --- a/apps/mobile/src/components/projectFaviconCache.test.ts +++ b/apps/mobile/src/components/projectFaviconCache.test.ts @@ -1,31 +1,37 @@ import { describe, expect, it } from "vite-plus/test"; import { + beginProjectFaviconRequest, hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, } from "./projectFaviconCache"; describe("project favicon cache", () => { - it("ignores an old URL error after a refreshed URL loads", () => { + it("ignores callbacks from a superseded URL", () => { const cacheKey = "environment-1:/workspace:v1-favicon.svg"; const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; - markProjectFaviconLoaded(cacheKey, expiredUrl); - markProjectFaviconLoaded(cacheKey, refreshedUrl); + const expiredRequest = beginProjectFaviconRequest(cacheKey, expiredUrl); + markProjectFaviconLoaded(expiredRequest); + const refreshedRequest = beginProjectFaviconRequest(cacheKey, refreshedUrl); - expect(markProjectFaviconFailed(cacheKey, expiredUrl)).toBe(false); + expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); + expect(markProjectFaviconFailed(expiredRequest)).toBe(false); expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); }); it("evicts the URL that actually failed", () => { const cacheKey = "environment-1:/workspace:v2-favicon.svg"; const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; + const request = beginProjectFaviconRequest(cacheKey, faviconUrl); - markProjectFaviconLoaded(cacheKey, faviconUrl); + markProjectFaviconLoaded(request); - expect(markProjectFaviconFailed(cacheKey, faviconUrl)).toBe(true); + expect(markProjectFaviconFailed(request)).toBe(true); expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); }); }); diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/components/projectFaviconCache.ts index 90a41fec050..02717dada7e 100644 --- a/apps/mobile/src/components/projectFaviconCache.ts +++ b/apps/mobile/src/components/projectFaviconCache.ts @@ -1,19 +1,34 @@ -const loadedFaviconUrls = new Map(); +export interface ProjectFaviconRequest { + readonly cacheKey: string | null; + readonly faviconUrl: string | null; +} -export function hasLoadedProjectFavicon(cacheKey: string | null) { - return cacheKey !== null && loadedFaviconUrls.has(cacheKey); +const currentFaviconRequests = new Map(); +const loadedFaviconKeys = new Set(); + +export function beginProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { + const currentRequest = cacheKey ? currentFaviconRequests.get(cacheKey) : undefined; + if (currentRequest?.faviconUrl === faviconUrl) return currentRequest; + + const request = { cacheKey, faviconUrl }; + if (cacheKey && faviconUrl) currentFaviconRequests.set(cacheKey, request); + return request; } -export function markProjectFaviconLoaded(cacheKey: string | null, faviconUrl: string | null) { - if (cacheKey && faviconUrl) loadedFaviconUrls.set(cacheKey, faviconUrl); +export function hasLoadedProjectFavicon(cacheKey: string | null) { + return cacheKey !== null && loadedFaviconKeys.has(cacheKey); } -export function markProjectFaviconFailed(cacheKey: string | null, faviconUrl: string | null) { - if (!cacheKey) return true; +export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { + if (request.cacheKey && currentFaviconRequests.get(request.cacheKey) !== request) return false; + + if (request.cacheKey) loadedFaviconKeys.add(request.cacheKey); + return true; +} - const loadedUrl = loadedFaviconUrls.get(cacheKey); - if (loadedUrl !== undefined && loadedUrl !== faviconUrl) return false; +export function markProjectFaviconFailed(request: ProjectFaviconRequest) { + if (request.cacheKey && currentFaviconRequests.get(request.cacheKey) !== request) return false; - loadedFaviconUrls.delete(cacheKey); + if (request.cacheKey) loadedFaviconKeys.delete(request.cacheKey); return true; } diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index fecf18a562d..568dc3739c0 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,11 +2,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -262,6 +264,31 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("buckets project favicon expiry after content hashing", () => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-expiry-", + }); + yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), ""); + + const bucketMs = 30 * 60 * 1000; + yield* TestClock.setTime(bucketMs - 1); + const crossingCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (algorithm, data) => + TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))), + }); + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto)); + + expect(result.expiresAt).toBe(3 * bucketMs); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("preserves structured project favicon resolution causes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index f69aa30fef5..c00f7f1a5e3 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -173,12 +173,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - const issuedAt = yield* Clock.currentTimeMillis; - const expiresAt = - input.resource._tag === "project-favicon" - ? (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * - PROJECT_FAVICON_TOKEN_BUCKET_MS - : issuedAt + ASSET_TOKEN_TTL_MS; + let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; @@ -372,6 +367,13 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); + if (claims.kind === "project-favicon") { + const issuedAt = yield* Clock.currentTimeMillis; + expiresAt = + (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * + PROJECT_FAVICON_TOKEN_BUCKET_MS; + claims = { ...claims, expiresAt }; + } const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { From 6ada303e323df4bc31043652df27213543dd84a6 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:28:36 -0400 Subject: [PATCH 6/7] fix(mobile): register favicon requests after commit --- apps/mobile/src/components/ProjectFavicon.tsx | 20 ++++++------ .../components/projectFaviconCache.test.ts | 23 ++++++++++++-- .../src/components/projectFaviconCache.ts | 31 +++++++++++++------ 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 28820698001..b6571dd070b 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,6 +1,6 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useRef, useState } from "react"; +import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { @@ -11,10 +11,10 @@ import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; import { beginProjectFaviconRequest, + createProjectFaviconRequest, hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, - type ProjectFaviconRequest, } from "./projectFaviconCache"; /* ─── Component ──────────────────────────────────────────────────────── */ @@ -58,15 +58,13 @@ function ProjectFaviconImage(props: { readonly size: number; }) { const iconMuted = useThemeColor("--color-icon-subtle"); - const faviconRequestRef = useRef(null); - if ( - faviconRequestRef.current === null || - faviconRequestRef.current.cacheKey !== props.cacheKey || - faviconRequestRef.current.faviconUrl !== props.faviconUrl - ) { - faviconRequestRef.current = beginProjectFaviconRequest(props.cacheKey, props.faviconUrl); - } - const faviconRequest = faviconRequestRef.current; + const faviconRequest = useMemo( + () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), + [props.cacheKey, props.faviconUrl], + ); + useLayoutEffect(() => { + beginProjectFaviconRequest(faviconRequest); + }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", diff --git a/apps/mobile/src/components/projectFaviconCache.test.ts b/apps/mobile/src/components/projectFaviconCache.test.ts index 03947f0bc52..60eefde0f70 100644 --- a/apps/mobile/src/components/projectFaviconCache.test.ts +++ b/apps/mobile/src/components/projectFaviconCache.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { beginProjectFaviconRequest, + createProjectFaviconRequest, hasLoadedProjectFavicon, markProjectFaviconFailed, markProjectFaviconLoaded, @@ -13,9 +14,11 @@ describe("project favicon cache", () => { const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg"; const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; - const expiredRequest = beginProjectFaviconRequest(cacheKey, expiredUrl); + const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); + beginProjectFaviconRequest(expiredRequest); markProjectFaviconLoaded(expiredRequest); - const refreshedRequest = beginProjectFaviconRequest(cacheKey, refreshedUrl); + const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); + beginProjectFaviconRequest(refreshedRequest); expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); expect(markProjectFaviconFailed(expiredRequest)).toBe(false); @@ -27,11 +30,25 @@ describe("project favicon cache", () => { it("evicts the URL that actually failed", () => { const cacheKey = "environment-1:/workspace:v2-favicon.svg"; const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; - const request = beginProjectFaviconRequest(cacheKey, faviconUrl); + const request = createProjectFaviconRequest(cacheKey, faviconUrl); + beginProjectFaviconRequest(request); markProjectFaviconLoaded(request); expect(markProjectFaviconFailed(request)).toBe(true); expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); }); + + it("does not supersede a request until the next request begins", () => { + const cacheKey = "environment-1:/workspace:v3-favicon.svg"; + const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; + const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; + const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); + beginProjectFaviconRequest(committedRequest); + + createProjectFaviconRequest(cacheKey, abandonedUrl); + + expect(markProjectFaviconLoaded(committedRequest)).toBe(true); + expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + }); }); diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/components/projectFaviconCache.ts index 02717dada7e..2204ea966a3 100644 --- a/apps/mobile/src/components/projectFaviconCache.ts +++ b/apps/mobile/src/components/projectFaviconCache.ts @@ -3,16 +3,17 @@ export interface ProjectFaviconRequest { readonly faviconUrl: string | null; } -const currentFaviconRequests = new Map(); +const currentFaviconUrls = new Map(); const loadedFaviconKeys = new Set(); -export function beginProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { - const currentRequest = cacheKey ? currentFaviconRequests.get(cacheKey) : undefined; - if (currentRequest?.faviconUrl === faviconUrl) return currentRequest; +export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { + return { cacheKey, faviconUrl }; +} - const request = { cacheKey, faviconUrl }; - if (cacheKey && faviconUrl) currentFaviconRequests.set(cacheKey, request); - return request; +export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { + if (request.cacheKey && request.faviconUrl) { + currentFaviconUrls.set(request.cacheKey, request.faviconUrl); + } } export function hasLoadedProjectFavicon(cacheKey: string | null) { @@ -20,14 +21,26 @@ export function hasLoadedProjectFavicon(cacheKey: string | null) { } export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { - if (request.cacheKey && currentFaviconRequests.get(request.cacheKey) !== request) return false; + if ( + request.cacheKey && + request.faviconUrl && + currentFaviconUrls.get(request.cacheKey) !== request.faviconUrl + ) { + return false; + } if (request.cacheKey) loadedFaviconKeys.add(request.cacheKey); return true; } export function markProjectFaviconFailed(request: ProjectFaviconRequest) { - if (request.cacheKey && currentFaviconRequests.get(request.cacheKey) !== request) return false; + if ( + request.cacheKey && + request.faviconUrl && + currentFaviconUrls.get(request.cacheKey) !== request.faviconUrl + ) { + return false; + } if (request.cacheKey) loadedFaviconKeys.delete(request.cacheKey); return true; From 0d0779cf74b2d7a5524c1ddd24bfa0a982c4c00f Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Thu, 30 Jul 2026 03:43:00 -0400 Subject: [PATCH 7/7] fix(mobile): bound favicon request state --- apps/mobile/src/components/ProjectFavicon.tsx | 20 ++-- .../components/projectFaviconCache.test.ts | 62 ++++++++++++- .../src/components/projectFaviconCache.ts | 91 ++++++++++++++----- 3 files changed, 140 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index b6571dd070b..d52aa05b446 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -62,15 +62,21 @@ function ProjectFaviconImage(props: { () => createProjectFaviconRequest(props.cacheKey, props.faviconUrl), [props.cacheKey, props.faviconUrl], ); + const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); useLayoutEffect(() => { - beginProjectFaviconRequest(faviconRequest); + if (faviconRequest === null) return; + + const endRequest = beginProjectFaviconRequest(faviconRequest); + setActiveFaviconRequest(faviconRequest); + return endRequest; }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", ); - const showImage = props.faviconUrl !== null && status === "loaded"; + const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; + const showImage = requestIsActive && status === "loaded"; return ( { const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg"; const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl); - beginProjectFaviconRequest(expiredRequest); + const endExpiredRequest = beginProjectFaviconRequest(expiredRequest); markProjectFaviconLoaded(expiredRequest); const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl); - beginProjectFaviconRequest(refreshedRequest); + const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest); expect(markProjectFaviconLoaded(expiredRequest)).toBe(false); expect(markProjectFaviconFailed(expiredRequest)).toBe(false); expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); expect(markProjectFaviconFailed(refreshedRequest)).toBe(true); expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRefreshedRequest(); + endExpiredRequest(); }); it("evicts the URL that actually failed", () => { const cacheKey = "environment-1:/workspace:v2-favicon.svg"; const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg"; const request = createProjectFaviconRequest(cacheKey, faviconUrl); - beginProjectFaviconRequest(request); + const endRequest = beginProjectFaviconRequest(request); markProjectFaviconLoaded(request); expect(markProjectFaviconFailed(request)).toBe(true); expect(hasLoadedProjectFavicon(cacheKey)).toBe(false); + + endRequest(); }); it("does not supersede a request until the next request begins", () => { @@ -44,11 +49,60 @@ describe("project favicon cache", () => { const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg"; const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg"; const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl); - beginProjectFaviconRequest(committedRequest); + const endCommittedRequest = beginProjectFaviconRequest(committedRequest); createProjectFaviconRequest(cacheKey, abandonedUrl); expect(markProjectFaviconLoaded(committedRequest)).toBe(true); expect(hasLoadedProjectFavicon(cacheKey)).toBe(true); + + endCommittedRequest(); + }); + + it("requires a cache key before creating a URL-bearing request", () => { + const firstUrl = "https://environment.example/api/assets/first/favicon.svg"; + const secondUrl = "https://environment.example/api/assets/second/favicon.svg"; + + expect(createProjectFaviconRequest(null, firstUrl)).toBeNull(); + expect(createProjectFaviconRequest(null, secondUrl)).toBeNull(); + }); + + it("restores the remaining active URL when a newer request ends", () => { + const cacheKey = "environment-1:/workspace:v4-favicon.svg"; + const firstRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/first/v4-favicon.svg", + ); + const secondRequest = createProjectFaviconRequest( + cacheKey, + "https://environment.example/api/assets/second/v4-favicon.svg", + ); + const endFirstRequest = beginProjectFaviconRequest(firstRequest); + const endSecondRequest = beginProjectFaviconRequest(secondRequest); + + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + endSecondRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(true); + endFirstRequest(); + expect(markProjectFaviconLoaded(firstRequest)).toBe(false); + }); + + it("bounds remembered loaded revisions", () => { + const firstCacheKey = "environment-1:/workspace:revision-0"; + let lastCacheKey = firstCacheKey; + + for (let revision = 0; revision < 300; revision++) { + lastCacheKey = `environment-1:/workspace:revision-${revision}`; + const request = createProjectFaviconRequest( + lastCacheKey, + `https://environment.example/api/assets/revision-${revision}/favicon.svg`, + ); + const endRequest = beginProjectFaviconRequest(request); + markProjectFaviconLoaded(request); + endRequest(); + } + + expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false); + expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true); }); }); diff --git a/apps/mobile/src/components/projectFaviconCache.ts b/apps/mobile/src/components/projectFaviconCache.ts index 2204ea966a3..da77d7613f2 100644 --- a/apps/mobile/src/components/projectFaviconCache.ts +++ b/apps/mobile/src/components/projectFaviconCache.ts @@ -1,47 +1,94 @@ export interface ProjectFaviconRequest { - readonly cacheKey: string | null; - readonly faviconUrl: string | null; + readonly cacheKey: string; + readonly faviconUrl: string; } -const currentFaviconUrls = new Map(); -const loadedFaviconKeys = new Set(); +interface ActiveFaviconRequests { + readonly urls: Map; + currentUrl: string; +} + +const MAX_LOADED_FAVICONS = 256; +const activeFaviconRequests = new Map(); +const loadedFaviconKeys = new Map(); +export function createProjectFaviconRequest( + cacheKey: string, + faviconUrl: string, +): ProjectFaviconRequest; +export function createProjectFaviconRequest( + cacheKey: string | null, + faviconUrl: string | null, +): ProjectFaviconRequest | null; export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { + if (!cacheKey || !faviconUrl) return null; return { cacheKey, faviconUrl }; } export function beginProjectFaviconRequest(request: ProjectFaviconRequest) { - if (request.cacheKey && request.faviconUrl) { - currentFaviconUrls.set(request.cacheKey, request.faviconUrl); + let activeRequests = activeFaviconRequests.get(request.cacheKey); + if (!activeRequests) { + activeRequests = { currentUrl: request.faviconUrl, urls: new Map() }; + activeFaviconRequests.set(request.cacheKey, activeRequests); } + + const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0; + activeRequests.urls.delete(request.faviconUrl); + activeRequests.urls.set(request.faviconUrl, activeCount + 1); + activeRequests.currentUrl = request.faviconUrl; + + let ended = false; + return () => { + if (ended) return; + ended = true; + + const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1; + if (remainingCount > 0) { + activeRequests.urls.set(request.faviconUrl, remainingCount); + return; + } + + activeRequests.urls.delete(request.faviconUrl); + if (activeRequests.urls.size === 0) { + if (activeFaviconRequests.get(request.cacheKey) === activeRequests) { + activeFaviconRequests.delete(request.cacheKey); + } + return; + } + + if (activeRequests.currentUrl === request.faviconUrl) { + activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!; + } + }; } export function hasLoadedProjectFavicon(cacheKey: string | null) { return cacheKey !== null && loadedFaviconKeys.has(cacheKey); } -export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { - if ( - request.cacheKey && - request.faviconUrl && - currentFaviconUrls.get(request.cacheKey) !== request.faviconUrl - ) { - return false; +function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { + return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; +} + +function rememberLoadedProjectFavicon(cacheKey: string) { + loadedFaviconKeys.delete(cacheKey); + loadedFaviconKeys.set(cacheKey, true); + + if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) { + loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!); } +} - if (request.cacheKey) loadedFaviconKeys.add(request.cacheKey); +export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { + if (!isCurrentProjectFaviconRequest(request)) return false; + + rememberLoadedProjectFavicon(request.cacheKey); return true; } export function markProjectFaviconFailed(request: ProjectFaviconRequest) { - if ( - request.cacheKey && - request.faviconUrl && - currentFaviconUrls.get(request.cacheKey) !== request.faviconUrl - ) { - return false; - } + if (!isCurrentProjectFaviconRequest(request)) return false; - if (request.cacheKey) loadedFaviconKeys.delete(request.cacheKey); + loadedFaviconKeys.delete(request.cacheKey); return true; }