Skip to content
Merged
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
57 changes: 45 additions & 12 deletions apps/mobile/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { SymbolView } from "./AppSymbol";
import { Image } from "expo-image";
import { useState } from "react";
import { useLayoutEffect, useMemo, 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<string>();
import {
beginProjectFaviconRequest,
createProjectFaviconRequest,
hasLoadedProjectFavicon,
markProjectFaviconFailed,
markProjectFaviconLoaded,
} from "./projectFaviconCache";

/* ─── Component ──────────────────────────────────────────────────────── */
export function ProjectFavicon(props: {
Expand All @@ -26,10 +33,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 (
<ProjectFaviconImage
key={faviconUrl}
key={cacheKey}
cacheKey={cacheKey}
faviconUrl={renderableFaviconUrl}
open={props.open}
projectTitle={props.projectTitle}
Expand All @@ -39,18 +51,32 @@ export function ProjectFavicon(props: {
}

function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
readonly faviconUrl: string | null;
readonly open?: boolean;
readonly projectTitle: string;
readonly size: number;
}) {
const iconMuted = useThemeColor("--color-icon-subtle");
const faviconRequest = useMemo(
() => createProjectFaviconRequest(props.cacheKey, props.faviconUrl),
[props.cacheKey, props.faviconUrl],
);
const [activeFaviconRequest, setActiveFaviconRequest] = useState<typeof faviconRequest>(null);
useLayoutEffect(() => {
if (faviconRequest === null) return;

const endRequest = beginProjectFaviconRequest(faviconRequest);
setActiveFaviconRequest(faviconRequest);
return endRequest;
}, [faviconRequest]);
Comment thread
cursor[bot] marked this conversation as resolved.

const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading",
hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading",
);

const showImage = props.faviconUrl !== null && status === "loaded";
const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
const showImage = requestIsActive && status === "loaded";

return (
<View
Expand All @@ -72,11 +98,15 @@ function ProjectFaviconImage(props: {
) : null}

{/* Favicon image (hidden until loaded) */}
{props.faviconUrl ? (
{requestIsActive ? (
<Image
key={faviconRequest.faviconUrl}
source={{
uri: props.faviconUrl,
uri: faviconRequest.faviconUrl,
cacheKey: faviconRequest.cacheKey,
}}
cachePolicy="memory-disk"
recyclingKey={faviconRequest.cacheKey}
accessibilityLabel={`${props.projectTitle} favicon`}
style={{
width: props.size,
Expand All @@ -86,10 +116,13 @@ function ProjectFaviconImage(props: {
}}
contentFit="contain"
onLoad={() => {
if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl);
if (!markProjectFaviconLoaded(faviconRequest)) return;
setStatus("loaded");
}}
onError={() => setStatus("error")}
onError={() => {
if (!markProjectFaviconFailed(faviconRequest)) return;
setStatus("error");
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
) : null}
</View>
Expand Down
108 changes: 108 additions & 0 deletions apps/mobile/src/components/projectFaviconCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vite-plus/test";

import {
beginProjectFaviconRequest,
createProjectFaviconRequest,
hasLoadedProjectFavicon,
markProjectFaviconFailed,
markProjectFaviconLoaded,
} from "./projectFaviconCache";

describe("project favicon cache", () => {
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";

const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl);
const endExpiredRequest = beginProjectFaviconRequest(expiredRequest);
markProjectFaviconLoaded(expiredRequest);
const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl);
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);
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", () => {
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);
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);
});
});
94 changes: 94 additions & 0 deletions apps/mobile/src/components/projectFaviconCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
export interface ProjectFaviconRequest {
readonly cacheKey: string;
readonly faviconUrl: string;
}

interface ActiveFaviconRequests {
readonly urls: Map<string, number>;
currentUrl: string;
}

const MAX_LOADED_FAVICONS = 256;
const activeFaviconRequests = new Map<string, ActiveFaviconRequests>();
const loadedFaviconKeys = new Map<string, true>();

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) {
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)!;
}
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function hasLoadedProjectFavicon(cacheKey: string | null) {
return cacheKey !== null && loadedFaviconKeys.has(cacheKey);
}

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!);
}
}

export function markProjectFaviconLoaded(request: ProjectFaviconRequest) {
if (!isCurrentProjectFaviconRequest(request)) return false;

rememberLoadedProjectFavicon(request.cacheKey);
return true;
}

export function markProjectFaviconFailed(request: ProjectFaviconRequest) {
if (!isCurrentProjectFaviconRequest(request)) return false;

loadedFaviconKeys.delete(request.cacheKey);
return true;
Comment thread
gabrielelpidio marked this conversation as resolved.
}
Loading
Loading