Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
22 changes: 17 additions & 5 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,7 +98,7 @@ 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")}
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/assets/AssetAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
gabrielelpidio marked this conversation as resolved.
Outdated
const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const faviconSeparatorIndex = faviconSuffix.indexOf("/");
expect(
Expand All @@ -229,6 +235,14 @@ describe("AssetAccess", () => {
),
).toEqual({ kind: "file", path: canonicalFaviconPath });

yield* fileSystem.writeFileString(faviconPath, "<svg>updated favicon</svg>");
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
50 changes: 36 additions & 14 deletions apps/server/src/assets/AssetAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
});
Expand All @@ -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)}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
} 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