Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
27 changes: 21 additions & 6 deletions apps/mobile/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
const loadedFaviconKeys = new Set<string>();

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

function ProjectFaviconImage(props: {
readonly cacheKey: string | null;
readonly faviconUrl: string | null;
readonly open?: boolean;
readonly projectTitle: string;
Expand All @@ -47,7 +56,7 @@ function ProjectFaviconImage(props: {
const iconMuted = useThemeColor("--color-icon-subtle");

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

const showImage = props.faviconUrl !== null && status === "loaded";
Expand Down Expand Up @@ -76,7 +85,10 @@ function ProjectFaviconImage(props: {
<Image
source={{
uri: props.faviconUrl,
...(props.cacheKey ? { cacheKey: props.cacheKey } : {}),
}}
cachePolicy="memory-disk"
recyclingKey={props.cacheKey}
accessibilityLabel={`${props.projectTitle} favicon`}
style={{
width: props.size,
Expand All @@ -86,10 +98,13 @@ function ProjectFaviconImage(props: {
}}
contentFit="contain"
onLoad={() => {
if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl);
if (props.cacheKey) loadedFaviconKeys.add(props.cacheKey);
Comment thread
gabrielelpidio marked this conversation as resolved.
Outdated
setStatus("loaded");
}}
onError={() => setStatus("error")}
onError={() => {
if (props.cacheKey) loadedFaviconKeys.delete(props.cacheKey);
setStatus("error");
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
) : null}
</View>
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,21 @@ describe("AssetAccess", () => {
prefix: "t3-asset-favicon-",
});
const faviconPath = path.join(root, "favicon.svg");
yield* fileSystem.writeFileString(faviconPath, "<svg />");
const initialFavicon = "<svg>a</svg>";
const updatedFavicon = "<svg>b</svg>";
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-f]{64}-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(
Expand All @@ -229,6 +238,14 @@ describe("AssetAccess", () => {
),
).toEqual({ kind: "file", path: canonicalFaviconPath });

yield* fileSystem.writeFileString(faviconPath, updatedFavicon);
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 },
Expand Down
61 changes: 47 additions & 14 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -44,6 +46,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,
Expand Down Expand Up @@ -166,10 +170,16 @@ 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;
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;

Expand Down Expand Up @@ -293,18 +303,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,
});
Expand All @@ -324,7 +334,30 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
relativePath,
expiresAt,
};
fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER;
if (relativePath && canonicalFaviconPath) {
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({
resource: input.resource,
cause,
}),
),
);
fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`;
} else {
fileName = PROJECT_FAVICON_FALLBACK_MARKER;
}
break;
}
}
Expand Down
128 changes: 128 additions & 0 deletions apps/web/src/components/ProjectFavicon.test.tsx
Original file line number Diff line number Diff line change
@@ -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<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
const index = nextIndex();
if (index >= slots.length) {
slots[index] =
typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
}
const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
const previous = slots[index] as T;
slots[index] =
typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
};
return [slots[index] as T, setValue];
},
};
});

vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
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<ProjectFaviconImageProps>;
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();
});
});
Loading
Loading