From c4c8260a30a6ab8c158ab379fca132ca438ebccc Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:46:12 +0200 Subject: [PATCH 1/6] fix: retain remote project icons during reconnects --- apps/mobile/src/components/ProjectFavicon.tsx | 22 ++-- apps/mobile/src/state/assets.ts | 8 +- .../src/components/ProjectFavicon.test.tsx | 18 ++-- apps/web/src/components/ProjectFavicon.tsx | 18 +--- apps/web/src/state/assets.ts | 11 +- .../client-runtime/src/state/assets.test.ts | 101 +++++++++++++++++- packages/client-runtime/src/state/assets.ts | 37 +++++++ 7 files changed, 179 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c60709baf4c9..a7e42acbd0b3 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -7,7 +7,10 @@ import { getProjectFaviconCacheKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { useAssetUrl } from "../state/assets"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { projectFaviconUrlAtom } from "../state/assets"; + import { beginProjectFaviconRequest, createProjectFaviconRequest, @@ -16,6 +19,8 @@ import { markProjectFaviconLoaded, } from "./projectFaviconCache"; +const EMPTY_FAVICON_URL = Atom.make(null); + /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; @@ -26,15 +31,14 @@ export function ProjectFavicon(props: { readonly faviconPath?: string | null; }) { const size = props.size ?? 42; - const faviconUrl = useAssetUrl( - props.environmentId, - props.workspaceRoot === null || props.workspaceRoot === undefined - ? null - : { - _tag: "project-favicon", + const faviconUrl = useAtomValue( + props.workspaceRoot == null + ? EMPTY_FAVICON_URL + : projectFaviconUrlAtom({ + environmentId: props.environmentId, cwd: props.workspaceRoot, - ...(props.faviconPath ? { path: props.faviconPath } : {}), - }, + faviconPath: props.faviconPath, + }), ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; const cacheKey = diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 400bdb6b705a..7e53745b9b5c 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -6,6 +6,7 @@ import { import { assetUrlStateFromResult, createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, EMPTY_ASSET_URL_ATOM, } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; @@ -16,13 +17,18 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; -import { usePreparedConnection } from "./session"; +import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); + const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-asset-connection-state:empty"), ); diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bfb5487031b7..98458cfa2dcd 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon" const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", - lastResource: null as unknown, + lastTarget: null as unknown, })); const hooks = vi.hoisted(() => { @@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({ DynamicIcon: "dynamic-icon", iconNames: ["alarm-clock", "folder-code"], })); -vi.mock("../assets/assetUrls", () => ({ - useAssetUrlState: (_environmentId: unknown, resource: unknown) => { - testState.lastResource = resource; - return { _tag: "Success", url: testState.faviconUrl }; +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => testState.faviconUrl, +})); +vi.mock("../state/assets", () => ({ + projectFaviconUrlAtom: (input: unknown) => { + testState.lastTarget = input; }, })); @@ -212,10 +214,10 @@ describe("ProjectFavicon", () => { faviconPath: "brand/icon.svg", }); - expect(testState.lastResource).toEqual({ - _tag: "project-favicon", + expect(testState.lastTarget).toMatchObject({ + environmentId: "environment-test", cwd: "/workspace-test", - path: "brand/icon.svg", + faviconPath: "brand/icon.svg", }); }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 2ebc6e267443..9499264cbd5d 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -30,7 +30,8 @@ import { import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; import { lazy, Suspense, useState } from "react"; -import { useAssetUrlState } from "../assets/assetUrls"; +import { useAtomValue } from "@effect/atom-react"; +import { projectFaviconUrlAtom } from "../state/assets"; import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; @@ -103,8 +104,7 @@ export function ProjectFavicon(input: { className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const state = useProjectFaviconAsset(input); - const src = state._tag === "Success" ? state.url : null; + const src = useAtomValue(projectFaviconUrlAtom(input)); if (input.projectIcon?.kind === "emoji") { return ; } @@ -165,18 +165,6 @@ export function ProjectFavicon(input: { ); } -export function useProjectFaviconAsset(input: { - readonly environmentId: EnvironmentId; - readonly cwd: string; - readonly faviconPath?: string | null | undefined; -}) { - return useAssetUrlState(input.environmentId, { - _tag: "project-favicon", - cwd: input.cwd, - ...(input.faviconPath ? { path: input.faviconPath } : {}), - }); -} - function ProjectFaviconFallback({ className, colorClassName, diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 5e31beb826b5..6125cc0f8573 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -1,5 +1,14 @@ -import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; +import { + createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, +} from "@t3tools/client-runtime/state/assets"; import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSession } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); + +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index d75e82281382..23ed21a5da23 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, InvalidAssetCollectionKeyError, parseAssetCollectionKey, } from "./assets.ts"; @@ -118,3 +121,97 @@ describe("createAssetEnvironmentAtoms", () => { ).not.toBe(assets.createUrls({ environmentId, resources })); }); }); + +describe("project favicon URL cache", () => { + it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); + let unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBeNull(); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-a/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + unmount(); + unmount = registry.mount(favicon); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(result, AsyncResult.initial()); + registry.set(connection, Option.some({ httpBaseUrl: "https://reconnected.test" })); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-b/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://reconnected.test/api/assets/token-b/icon.svg"); + + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-c/project-favicon-missing", + }), + ); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + registry.set(connection, Option.none()); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("does not reuse another environment, workspace, or selected icon's cached URL", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token/icon.svg", + }), + ); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })), + }); + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const unmount = registry.mount(favicon(target)); + try { + expect(registry.get(favicon(target))).toBe("https://remote.test/api/assets/token/icon.svg"); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect( + registry.get(favicon({ ...target, environmentId: EnvironmentId.make("other") })), + ).toBeNull(); + expect(registry.get(favicon({ ...target, cwd: "/other" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: "brand.svg" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: null }))).toBe( + "https://remote.test/api/assets/token/icon.svg", + ); + } finally { + unmount(); + registry.dispose(); + } + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index f2f82def3dd7..e17d0163194d 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -4,6 +4,7 @@ import { EnvironmentId, WS_METHODS, } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -112,3 +113,39 @@ export function createAssetEnvironmentAtoms( }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), }; } + +/** + * Keeps project icons visible while their environment reconnects. Each resource + * owns its last resolved URL, including a confirmed missing-icon response. + */ +export function createProjectFaviconUrlAtomFamily(input: { + readonly createUrl: (target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Atom.Atom>; + readonly preparedConnection: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; +}) { + const decodeKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), + ); + const family = Atom.family((key: string) => { + const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); + const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; + return Atom.make((get): string | null => { + const result = get(input.createUrl({ environmentId, input: { resource } })); + const connection = get(input.preparedConnection(environmentId)); + const state = assetUrlStateFromResult( + result, + Option.isSome(connection) ? connection.value.httpBaseUrl : null, + ); + return state._tag === "Success" ? state.url : Option.getOrNull(get.self()); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + }); + return (target: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; + }) => family(JSON.stringify([target.environmentId, target.cwd, target.faviconPath || null])); +} From 1933a1476eb275c2929ee4ba1275097a3776926d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:49:55 +0200 Subject: [PATCH 2/6] test(web): preserve session exports in preview mock --- apps/web/src/components/preview/PreviewView.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 9456daef72d8..2a146834012e 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({ useThreadRecentHistory: () => EMPTY_HISTORY, })); -vi.mock("~/state/session", () => ({ +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), readPreparedConnection: mocks.readPreparedConnection, })); From 176ee22edb3d1ac792bbc610952eb88ed3382b67 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:04:54 -0700 Subject: [PATCH 3/6] fix(clients): persist small project icons across reloads --- apps/mobile/src/components/ProjectFavicon.tsx | 13 +- .../src/connection/environment-cache-store.ts | 10 +- .../src/lib/projectFaviconCache.test.ts | 83 +++++++++ apps/mobile/src/lib/projectFaviconCache.ts | 62 +++++++ apps/mobile/src/state/assets.ts | 2 + apps/web/src/assets/projectFaviconCache.ts | 80 ++++++++ apps/web/src/components/ProjectFavicon.tsx | 16 +- apps/web/src/connection/storage.ts | 3 + apps/web/src/state/assets.ts | 2 + packages/client-runtime/package.json | 4 + .../src/projectFaviconCache.test.ts | 174 ++++++++++++++++++ .../client-runtime/src/projectFaviconCache.ts | 158 ++++++++++++++++ .../client-runtime/src/state/assets.test.ts | 62 +++++++ packages/client-runtime/src/state/assets.ts | 34 +++- packages/shared/src/projectFavicon.ts | 8 + 15 files changed, 683 insertions(+), 28 deletions(-) create mode 100644 apps/mobile/src/lib/projectFaviconCache.test.ts create mode 100644 apps/mobile/src/lib/projectFaviconCache.ts create mode 100644 apps/web/src/assets/projectFaviconCache.ts create mode 100644 packages/client-runtime/src/projectFaviconCache.test.ts create mode 100644 packages/client-runtime/src/projectFaviconCache.ts diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index a7e42acbd0b3..f4e5f4986621 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -4,7 +4,7 @@ import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { - getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; import { useAtomValue } from "@effect/atom-react"; @@ -43,7 +43,7 @@ export function ProjectFavicon(props: { const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; const cacheKey = renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) : null; return ( @@ -79,7 +79,9 @@ function ProjectFaviconImage(props: { }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", + props.faviconUrl?.startsWith("data:image/") || hasLoadedProjectFavicon(props.cacheKey) + ? "loaded" + : "loading", ); const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; @@ -110,9 +112,10 @@ function ProjectFaviconImage(props: { key={faviconRequest.faviconUrl} source={{ uri: faviconRequest.faviconUrl, - cacheKey: faviconRequest.cacheKey, }} - cachePolicy="memory-disk" + cachePolicy={ + faviconRequest.faviconUrl.startsWith("data:image/") ? "memory" : "memory-disk" + } recyclingKey={faviconRequest.cacheKey} accessibilityLabel={`${props.projectTitle} favicon`} style={{ diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index ad5ef13b62d5..3a4730477545 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -126,7 +127,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -237,9 +238,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - database - .clearEnvironmentCache(environmentId) - .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + database.clearEnvironmentCache(environmentId).pipe( + Effect.mapError(mapDatabaseError("clear-environment")), + Effect.tap(() => Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId))), + ), ), }); }); diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconCache.test.ts new file mode 100644 index 000000000000..05d85183facd --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; + +const native = vi.hoisted(() => ({ + load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ + width: options.maxWidth, + height: options.maxHeight, + release: vi.fn(), + })), + write: vi.fn(async () => {}), + path: vi.fn(async () => "/cache/thumbnail"), + read: vi.fn(), + remove: vi.fn(), +})); +vi.mock("expo-image", () => ({ + Image: { + loadAsync: native.load, + writeToCacheAsync: native.write, + getCachePathAsync: native.path, + }, +})); +vi.mock("expo-file-system", () => ({ + File: class { + size = 24_000; + base64 = native.read; + delete = native.remove; + }, +})); + +import { createProjectFaviconThumbnail } from "./projectFaviconCache"; + +const png = "iVBORw0KGgoAAAAA"; + +beforeEach(() => { + vi.clearAllMocks(); + native.read.mockReset().mockResolvedValue(png); + native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ + width: maxWidth, + height: maxWidth, + release: vi.fn(), + })); +}); + +describe("mobile project icon thumbnails", () => { + it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { + native.read.mockResolvedValueOnce( + `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + const thumbnail = await createProjectFaviconThumbnail( + "https://remote/icon.png", + new AbortController().signal, + ); + expect(thumbnail).toBe(`data:image/png;base64,${png}`); + expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); + expect(native.remove).toHaveBeenCalledTimes(2); + for (const call of native.load.mock.results) + expect((await call.value).release).toHaveBeenCalledOnce(); + }); + + it("releases a decoded image when its request was canceled", async () => { + const controller = new AbortController(); + const release = vi.fn(); + native.load.mockImplementationOnce(async () => { + controller.abort(); + return { width: 96, height: 96, release }; + }); + await expect( + createProjectFaviconThumbnail("https://remote/icon.png", controller.signal), + ).rejects.toThrow(); + expect(release).toHaveBeenCalledOnce(); + expect(native.write).not.toHaveBeenCalled(); + }); + + it("rejects an image the native decoder did not downsize", async () => { + const release = vi.fn(); + native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); + await expect( + createProjectFaviconThumbnail("https://remote/icon.png", new AbortController().signal), + ).rejects.toThrow("not resized"); + expect(native.write).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconCache.ts new file mode 100644 index 000000000000..82e0e6cabb94 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.ts @@ -0,0 +1,62 @@ +import { + createProjectFaviconCache, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, +} from "@t3tools/client-runtime/project-favicon-cache"; + +export async function createProjectFaviconThumbnail(url: string, signal: AbortSignal) { + const [{ Image }, { File }] = await Promise.all([ + import("expo-image"), + import("expo-file-system"), + ]); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + signal.throwIfAborted(); + const image = await Image.loadAsync(url, { maxWidth: size, maxHeight: size }); + const cacheKey = `t3-favicon-thumbnail:${size}:${url}`; + try { + signal.throwIfAborted(); + if (image.width > size || image.height > size) { + throw new Error("Project icon was not resized."); + } + await Image.writeToCacheAsync(image, cacheKey); + const path = await Image.getCachePathAsync(cacheKey); + if (!path) throw new Error("Project icon thumbnail was not written."); + const file = new File(path.startsWith("file:") ? path : `file://${path}`); + try { + if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; + const base64 = await file.base64(); + // SDWebImage chooses JPEG for opaque images and PNG for transparency. + const mimeType = base64.startsWith("/9j/") + ? "image/jpeg" + : base64.startsWith("iVBORw0KGgo") + ? "image/png" + : null; + if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); + const dataUrl = `data:${mimeType};base64,${base64}`; + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } finally { + file.delete(); + } + } finally { + image.release(); + } + } + throw new Error("Project icon thumbnail exceeds the cache limit."); +} + +async function cacheFile() { + const { File, Paths } = await import("expo-file-system"); + return new File(Paths.cache, "t3-project-favicons-v1.json"); +} + +export const projectFaviconCache = createProjectFaviconCache({ + async read() { + const file = await cacheFile(); + return file.exists ? file.text() : null; + }, + async write(json) { + const file = await cacheFile(); + file.write(json); + }, + thumbnail: createProjectFaviconThumbnail, +}); diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 7e53745b9b5c..15cbd1d9a89f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -16,6 +16,7 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; @@ -25,6 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, }); diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts new file mode 100644 index 000000000000..2ffd9cc45672 --- /dev/null +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -0,0 +1,80 @@ +import { + createProjectFaviconCache, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, +} from "@t3tools/client-runtime/project-favicon-cache"; + +let database: Promise | undefined; + +function openDatabase() { + return (database ??= new Promise((resolve, reject) => { + const request = indexedDB.open("t3code:project-favicons", 1); + request.addEventListener("upgradeneeded", () => request.result.createObjectStore("thumbnails")); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); + })); +} + +export async function createProjectFaviconThumbnail(url: string, signal: AbortSignal) { + const image = new Image(); + image.crossOrigin = "anonymous"; + try { + await new Promise((resolve, reject) => { + const abort = () => finish(signal.reason); + const loaded = () => finish(); + const failed = () => finish(new Error("Could not decode project icon.")); + const finish = (error?: unknown) => { + signal.removeEventListener("abort", abort); + image.removeEventListener("load", loaded); + image.removeEventListener("error", failed); + if (error) reject(error); + else resolve(); + }; + signal.addEventListener("abort", abort, { once: true }); + image.addEventListener("load", loaded, { once: true }); + image.addEventListener("error", failed, { once: true }); + if (signal.aborted) finish(signal.reason); + else image.src = url; + }); + signal.throwIfAborted(); + const canvas = document.createElement("canvas"); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + const scale = Math.min(1, size / image.naturalWidth, size / image.naturalHeight); + canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); + canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas is unavailable."); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL("image/webp", 0.85); + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } + throw new Error("Project icon thumbnail exceeds the cache limit."); + } finally { + image.src = ""; + } +} + +export const projectFaviconCache = createProjectFaviconCache({ + async read() { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const request = db.transaction("thumbnails", "readonly").objectStore("thumbnails").get("v1"); + request.addEventListener("success", () => + resolve(typeof request.result === "string" ? request.result : null), + ); + request.addEventListener("error", () => reject(request.error)); + }); + }, + async write(json) { + const db = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction("thumbnails", "readwrite"); + transaction.objectStore("thumbnails").put(json, "v1"); + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); + }, + thumbnail: createProjectFaviconThumbnail, +}); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 9499264cbd5d..19467adde404 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,6 +1,6 @@ import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; import { - getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; import { @@ -36,7 +36,6 @@ import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Map(); const DynamicIcon = lazy(() => import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), ); @@ -150,12 +149,11 @@ export function ProjectFavicon(input: { ); } - const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath); return ( | undefined; readonly fallbackEmoji?: string | undefined; readonly fallbackColorClassName?: string | undefined; }) { - const [displayedSrc, setDisplayedSrc] = useState( - () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, + const [displayedSrc, setDisplayedSrc] = useState(() => + src.startsWith("data:image/") ? src : null, ); const isLoading = displayedSrc !== src; const handleLoadError = (failedSrc: string) => { - if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { - loadedProjectFaviconSrcs.delete(cacheKey); - } setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); }; @@ -244,7 +237,6 @@ function ProjectFaviconImage({ alt="" className="hidden" onLoad={() => { - loadedProjectFaviconSrcs.set(cacheKey, src); setDisplayedSrc(src); }} onError={() => handleLoadError(src)} diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 0a1183d48abd..8ec2b16add76 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -33,6 +33,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; const DATABASE_NAME = "t3code:connection-runtime"; const DATABASE_VERSION = 4; @@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext( const cacheStore = EnvironmentCacheStore.of({ loadShell: (environmentId) => readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), Effect.flatMap((raw) => { if (typeof raw !== "string") { return Effect.succeed(Option.none()); @@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext( clear: (environmentId) => Effect.all( [ + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)), removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), removeDatabaseValuesInRange( database, diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 6125cc0f8573..d1ef71f8662d 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -4,11 +4,13 @@ import { } from "@t3tools/client-runtime/state/assets"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; import { environmentSession } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, createUrl: assetEnvironment.createUrl, preparedConnection: environmentSession.preparedConnectionValueAtom, }); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 68f5bad51474..fe08435ea3cd 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./project-favicon-cache": { + "types": "./src/projectFaviconCache.ts", + "default": "./src/projectFaviconCache.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts new file mode 100644 index 000000000000..07e19293b39b --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { + createProjectFaviconCache, + PROJECT_FAVICON_CACHE_MAX_BYTES, + PROJECT_FAVICON_CACHE_MAX_ENTRIES, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, +} from "./projectFaviconCache.ts"; + +const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; +const url = "https://remote.test/api/assets/token-a/vabc-icon.svg"; +const image = "data:image/png;base64,aWNvbg=="; +const replacement = "data:image/png;base64,bmV3"; +const signal = () => new AbortController().signal; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fixture() { + let persisted: string | null = null; + const thumbnail = vi.fn(async () => image); + const storage = { + read: async () => persisted, + write: async (json: string) => { + persisted = json; + }, + thumbnail, + }; + return { + storage, + thumbnail, + cache: createProjectFaviconCache(storage), + persisted: () => JSON.parse(persisted ?? "[]") as Array<{ dataUrl: string }>, + }; +} + +describe("persistent project favicon cache", () => { + it("restores image bytes in a fresh client before any remote response", async () => { + const { cache, storage, thumbnail } = fixture(); + expect(await cache.resolve(target, url, signal())).toBe(image); + await cache.flush(); + const reloaded = createProjectFaviconCache(storage); + await reloaded.hydrate(); + expect(reloaded.peek(target)).toBe(image); + expect(await reloaded.resolve(target, null, signal())).toBe(image); + expect(thumbnail).toHaveBeenCalledTimes(1); + }); + + it("reuses the thumbnail when signed URLs or connection origins change", async () => { + const { cache, thumbnail } = fixture(); + await cache.resolve(target, url, signal()); + expect( + await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()), + ).toBe(image); + expect(thumbnail).toHaveBeenCalledTimes(1); + }); + + it("keeps the old image during refresh and failures, then persists its replacement", async () => { + const { cache, thumbnail, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + thumbnail.mockImplementationOnce(() => next.promise); + const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + expect(cache.peek(target)).toBe(image); + next.resolve(replacement); + expect(await refreshing).toBe(replacement); + thumbnail.mockRejectedValueOnce(new Error("offline")); + expect(await cache.resolve(target, url, signal())).toBe(replacement); + await cache.flush(); + expect(await createProjectFaviconCache(storage).resolve(target, null, signal())).toBe( + replacement, + ); + }); + + it("persists confirmed removal and ignores an aborted older thumbnail", async () => { + const { cache, thumbnail, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + const started = deferred(); + thumbnail.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const controller = new AbortController(); + const pending = cache.resolve(target, url.replace("vabc", "vdef"), controller.signal); + await started.promise; + controller.abort(); + expect( + await cache.resolve( + target, + "https://remote.test/api/assets/token/project-favicon-missing", + signal(), + ), + ).toBeNull(); + next.resolve(replacement); + await pending; + await cache.flush(); + expect(await createProjectFaviconCache(storage).resolve(target, null, signal())).toBeNull(); + }); + + it("isolates environments, workspaces, and icon selections", async () => { + const { cache } = fixture(); + await cache.resolve(target, url, signal()); + expect(cache.peek({ ...target, faviconPath: null })).toBe(image); + expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/other" })).toBeNull(); + expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull(); + }); + + it("does not restore images for an environment removed during a download", async () => { + const { cache, thumbnail } = fixture(); + const next = deferred(); + const started = deferred(); + thumbnail.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const pending = cache.resolve(target, url, signal()); + await started.promise; + await cache.clearEnvironment(target.environmentId); + next.resolve(image); + await pending; + expect(cache.peek(target)).toBeNull(); + }); + + it("bounds individual thumbnails, total thumbnail bytes, and entry count", async () => { + const { cache, thumbnail, persisted } = fixture(); + thumbnail.mockResolvedValueOnce( + `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + expect(await cache.resolve(target, url, signal())).toBe(url); + expect(cache.peek(target)).toBeNull(); + const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`; + thumbnail.mockResolvedValue(large); + for (let i = 0; i < 40; i++) { + await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal()); + } + await cache.flush(); + expect( + persisted().reduce((total, entry) => total + entry.dataUrl.length, 0), + ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES); + expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large); + thumbnail.mockResolvedValue(image); + for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) { + await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal()); + } + await cache.flush(); + expect(persisted()).toHaveLength(PROJECT_FAVICON_CACHE_MAX_ENTRIES); + expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull(); + }); + + it.each(["corrupt JSON", "storage unavailable"])("tolerates %s", async (failure) => { + const cache = createProjectFaviconCache({ + read: async () => { + if (failure === "storage unavailable") throw new Error(failure); + return "invalid JSON"; + }, + write: async () => { + throw new Error("quota exceeded"); + }, + thumbnail: async () => image, + }); + expect(await cache.resolve(target, url, signal())).toBe(image); + await cache.flush(); + expect(cache.peek(target)).toBe(image); + }); +}); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts new file mode 100644 index 000000000000..3bb356f8e96f --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -0,0 +1,158 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { + getProjectFaviconCacheKey, + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Schema from "effect/Schema"; + +export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; +export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; + +export interface ProjectFaviconTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +} + +const Thumbnail = Schema.String.check( + Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH), + Schema.isPattern(/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/), +); +const Entry = Schema.Struct({ + environmentId: EnvironmentId, + cwd: Schema.String, + faviconPath: Schema.NullOr(Schema.String), + revision: Schema.String, + dataUrl: Thumbnail, +}); +const decodeEntries = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Entry))); +const isThumbnail = Schema.is(Thumbnail); + +function keyFor(target: ProjectFaviconTarget) { + return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath); +} + +/** Stores small, self-contained images so startup never needs an old signed URL. */ +export function createProjectFaviconCache(input: { + readonly read: () => Promise; + readonly write: (json: string) => Promise; + readonly thumbnail: (url: string, signal: AbortSignal) => Promise; +}) { + const entries = new Map(); + const environmentRevisions = new Map(); + let hydration: Promise | undefined; + let writer: Promise | undefined; + let dirty = false; + + const trim = () => { + let bytes = 0; + for (const entry of entries.values()) bytes += entry.dataUrl.length; + while ( + entries.size > PROJECT_FAVICON_CACHE_MAX_ENTRIES || + bytes > PROJECT_FAVICON_CACHE_MAX_BYTES + ) { + const oldest = entries.entries().next().value; + if (!oldest) break; + bytes -= oldest[1].dataUrl.length; + entries.delete(oldest[0]); + } + }; + + const hydrate = () => + (hydration ??= (async () => { + try { + const json = await input.read(); + if (json === null) return; + for (const entry of decodeEntries(json)) entries.set(keyFor(entry), entry); + trim(); + } catch { + // A missing, corrupt, or unavailable cache must not prevent startup. + } + })()); + + const persist = () => { + dirty = true; + return (writer ??= (async () => { + while (dirty) { + dirty = false; + try { + await input.write(JSON.stringify([...entries.values()])); + } catch { + // Keep the in-memory thumbnail if local storage is full or unavailable. + } + } + })().finally(() => { + writer = undefined; + if (dirty) void persist(); + })); + }; + + const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; + + const resolve = async ( + target: ProjectFaviconTarget, + url: string | null, + signal: AbortSignal, + ): Promise => { + const environmentRevision = environmentRevisions.get(target.environmentId) ?? 0; + await hydrate(); + if (signal.aborted || url === null) return peek(target); + const key = keyFor(target); + if (isProjectFaviconFallbackUrl(url)) { + if (entries.delete(key)) void persist(); + return null; + } + const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url); + const cached = entries.get(key); + if (cached) { + entries.delete(key); + entries.set(key, cached); + if (cached.revision === revision) return cached.dataUrl; + } + try { + const dataUrl = await input.thumbnail(url, signal); + if ( + signal.aborted || + environmentRevision !== (environmentRevisions.get(target.environmentId) ?? 0) + ) + return peek(target); + if (isThumbnail(dataUrl)) { + entries.set(key, { + environmentId: target.environmentId, + cwd: target.cwd, + faviconPath: target.faviconPath || null, + revision, + dataUrl, + }); + trim(); + void persist(); + return dataUrl; + } + } catch { + // An outage or failed decode leaves the last successful image visible. + } + return peek(target) ?? url; + }; + + return { + hydrate, + peek, + resolve, + async clearEnvironment(environmentId: EnvironmentId) { + environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); + await hydrate(); + for (const [key, entry] of entries) { + if (entry.environmentId === environmentId) entries.delete(key); + } + await persist(); + }, + async flush() { + await writer; + }, + }; +} + +export type ProjectFaviconCache = ReturnType; diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index 23ed21a5da23..b366367aca9a 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createProjectFaviconCache } from "../projectFaviconCache.ts"; import { createAssetEnvironmentAtoms, createProjectFaviconUrlAtomFamily, @@ -123,6 +124,67 @@ describe("createAssetEnvironmentAtoms", () => { }); describe("project favicon URL cache", () => { + it("renders a persisted thumbnail immediately in a fresh registry and refreshes it remotely", async () => { + const image = "data:image/png;base64,aWNvbg=="; + const replacement = "data:image/png;base64,bmV3"; + let stored: string | null = null; + const storage = { + read: async () => stored, + write: async (json: string) => { + stored = json; + }, + thumbnail: async () => image, + }; + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const previousCache = createProjectFaviconCache(storage); + await previousCache.resolve( + target, + "https://remote.test/api/assets/old/v1-icon.png", + new AbortController().signal, + ); + await previousCache.flush(); + const cache = createProjectFaviconCache({ ...storage, thumbnail: async () => replacement }); + await cache.hydrate(); + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make>(Option.none()); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + imageCache: cache, + })(target); + const unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBe(image); + let unsubscribe = () => {}; + const refreshed = new Promise((resolve) => { + unsubscribe = registry.subscribe(favicon, (value) => { + if (value === replacement) resolve(); + }); + }); + registry.set(connection, Option.some({ httpBaseUrl: "https://remote.test" })); + registry.set( + result, + AsyncResult.success({ + relativeUrl: "/api/assets/new/v2-icon.png", + expiresAt: 4_000_000_000_000, + }), + ); + expect(registry.get(favicon)).toBe(image); + await refreshed; + unsubscribe(); + expect(registry.get(favicon)).toBe(replacement); + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("offline"))); + expect(registry.get(favicon)).toBe(replacement); + } finally { + unmount(); + registry.dispose(); + } + }); + it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => { const registry = AtomRegistry.make(); const result = Atom.make>( diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e17d0163194d..9eb77444d45c 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -4,11 +4,17 @@ import { EnvironmentId, WS_METHODS, } from "@t3tools/contracts"; +import { + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; @@ -119,6 +125,7 @@ export function createAssetEnvironmentAtoms( * owns its last resolved URL, including a confirmed missing-icon response. */ export function createProjectFaviconUrlAtomFamily(input: { + readonly imageCache?: ProjectFaviconCache; readonly createUrl: (target: { readonly environmentId: EnvironmentId; readonly input: { readonly resource: AssetResource }; @@ -133,8 +140,9 @@ export function createProjectFaviconUrlAtomFamily(input: { const family = Atom.family((key: string) => { const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; - return Atom.make((get): string | null => { - const result = get(input.createUrl({ environmentId, input: { resource } })); + const request = input.createUrl({ environmentId, input: { resource } }); + const resolvedUrl = Atom.make((get): string | null => { + const result = get(request); const connection = get(input.preparedConnection(environmentId)); const state = assetUrlStateFromResult( result, @@ -142,10 +150,22 @@ export function createProjectFaviconUrlAtomFamily(input: { ); return state._tag === "Success" ? state.url : Option.getOrNull(get.self()); }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + const cache = input.imageCache; + if (!cache) return resolvedUrl; + + const target = { environmentId, cwd, faviconPath: path }; + const image = Atom.make((get) => { + get(request); + const url = get(resolvedUrl); + return Effect.promise((signal) => cache.resolve(target, url, signal)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + + return Atom.make((get): string | null => { + const result = get(image); + if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null; + return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); }); - return (target: { - readonly environmentId: EnvironmentId; - readonly cwd: string; - readonly faviconPath?: string | null | undefined; - }) => family(JSON.stringify([target.environmentId, target.cwd, target.faviconPath || null])); + return (target: ProjectFaviconTarget) => + family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath)); } diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index eebc1a8a1b63..b6fd9e56f3a6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,13 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconResourceKey( + environmentId: string, + workspaceRoot: string, + faviconPath?: string | null, +) { + return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]); +} + export function getProjectFaviconCacheKey( environmentId: string, workspaceRoot: string, From 8090a471da9a8e01c68f2497582e75355919da63 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:36:38 -0700 Subject: [PATCH 4/6] fix(clients): store project icons as served instead of canvas thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SVG favicons without width/height attributes report a 300x150 (or, on older Firefox, 0x0) natural size, so the canvas thumbnail letterboxed the icon at half size or collapsed it to one pixel and then persisted that image. Fetch the icon bytes and inline them when they fit the cache limit; only bitmaps larger than that go through a platform downscaler, and large SVGs stay remote. Persist one record per icon rather than a single JSON blob so tabs cannot overwrite each other's entries. On mobile the records live in client_cache so Settings → Client storage counts and clears them, and clearing also drops the in-memory images. Remote mobile icons key expo-image's disk cache by revision again so signed-token rotation reuses cached bytes, and a changed icon starts from the loading state. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/components/ProjectFavicon.tsx | 19 +- .../environment-cache-store.test.ts | 6 + .../src/connection/environment-cache-store.ts | 4 +- .../src/lib/projectFaviconCache.test.ts | 18 +- apps/mobile/src/lib/projectFaviconCache.ts | 81 +++++-- .../mobile/src/persistence/mobile-database.ts | 23 +- apps/mobile/src/state/client-cache-state.ts | 8 +- apps/web/src/assets/projectFaviconCache.ts | 99 +++++---- .../src/projectFaviconCache.test.ts | 210 +++++++++++++----- .../client-runtime/src/projectFaviconCache.ts | 164 +++++++++----- .../client-runtime/src/state/assets.test.ts | 16 +- 11 files changed, 443 insertions(+), 205 deletions(-) diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index f4e5f4986621..932fc6779f20 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -4,6 +4,7 @@ import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { + getProjectFaviconCacheKey, getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; @@ -41,9 +42,13 @@ export function ProjectFavicon(props: { }), ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + // Inline images are self-contained; remote URLs key on their revision so signed-token + // rotation reuses the disk cache while a changed icon starts from the loading state. const cacheKey = renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; return ( @@ -79,7 +84,7 @@ function ProjectFaviconImage(props: { }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - props.faviconUrl?.startsWith("data:image/") || hasLoadedProjectFavicon(props.cacheKey) + props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", ); @@ -110,12 +115,12 @@ function ProjectFaviconImage(props: { {requestIsActive ? ( Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + listCache: (kind) => + Effect.sync(() => + [...values.entries()] + .filter(([key]) => key.split(":")[1] === kind) + .map(([, payload]) => payload), + ), saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => Effect.sync(() => { values.set(cacheId(environmentId, kind, cacheKey), payload); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 3a4730477545..846df793c285 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -238,9 +238,9 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - database.clearEnvironmentCache(environmentId).pipe( + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.andThen(database.clearEnvironmentCache(environmentId)), Effect.mapError(mapDatabaseError("clear-environment")), - Effect.tap(() => Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId))), ), ), }); diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconCache.test.ts index 05d85183facd..adde56fbf0b1 100644 --- a/apps/mobile/src/lib/projectFaviconCache.test.ts +++ b/apps/mobile/src/lib/projectFaviconCache.test.ts @@ -27,9 +27,10 @@ vi.mock("expo-file-system", () => ({ }, })); -import { createProjectFaviconThumbnail } from "./projectFaviconCache"; +import { downscaleProjectFavicon } from "./projectFaviconCache"; const png = "iVBORw0KGgoAAAAA"; +const image = { url: "https://remote/icon.png" }; beforeEach(() => { vi.clearAllMocks(); @@ -46,10 +47,7 @@ describe("mobile project icon thumbnails", () => { native.read.mockResolvedValueOnce( `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, ); - const thumbnail = await createProjectFaviconThumbnail( - "https://remote/icon.png", - new AbortController().signal, - ); + const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); expect(thumbnail).toBe(`data:image/png;base64,${png}`); expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); expect(native.remove).toHaveBeenCalledTimes(2); @@ -64,9 +62,7 @@ describe("mobile project icon thumbnails", () => { controller.abort(); return { width: 96, height: 96, release }; }); - await expect( - createProjectFaviconThumbnail("https://remote/icon.png", controller.signal), - ).rejects.toThrow(); + await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); expect(release).toHaveBeenCalledOnce(); expect(native.write).not.toHaveBeenCalled(); }); @@ -74,9 +70,9 @@ describe("mobile project icon thumbnails", () => { it("rejects an image the native decoder did not downsize", async () => { const release = vi.fn(); native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); - await expect( - createProjectFaviconThumbnail("https://remote/icon.png", new AbortController().signal), - ).rejects.toThrow("not resized"); + await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( + "not resized", + ); expect(native.write).not.toHaveBeenCalled(); expect(release).toHaveBeenCalledOnce(); }); diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconCache.ts index 82e0e6cabb94..ac8ed6b4775d 100644 --- a/apps/mobile/src/lib/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconCache.ts @@ -1,31 +1,56 @@ import { createProjectFaviconCache, + createProjectFaviconImageLoader, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, + type ProjectFaviconEntry, } from "@t3tools/client-runtime/project-favicon-cache"; +import * as Effect from "effect/Effect"; -export async function createProjectFaviconThumbnail(url: string, signal: AbortSignal) { +import * as MobileDatabase from "../persistence/mobile-database"; + +const CACHE_KIND = "project-favicon"; +const CACHE_SCHEMA_VERSION = 1; + +// The runtime's persistence layer owns the cache store that hydrates this module, so +// it is loaded on first use rather than at import time. +const runDatabase = async ( + use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, +) => { + const { runtime } = await import("./runtime"); + return runtime.runPromise(MobileDatabase.MobileDatabase.pipe(Effect.flatMap(use))); +}; + +/** + * Rasterizes a bitmap that is too large to inline. The native decoder writes the + * downsized frame to expo-image's disk cache, which is the only encode path it + * exposes; the temporary entry is removed once its bytes are read. + */ +export async function downscaleProjectFavicon( + image: { readonly url: string }, + signal: AbortSignal, +) { const [{ Image }, { File }] = await Promise.all([ import("expo-image"), import("expo-file-system"), ]); for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { signal.throwIfAborted(); - const image = await Image.loadAsync(url, { maxWidth: size, maxHeight: size }); - const cacheKey = `t3-favicon-thumbnail:${size}:${url}`; + const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); + const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; try { signal.throwIfAborted(); - if (image.width > size || image.height > size) { + if (decoded.width > size || decoded.height > size) { throw new Error("Project icon was not resized."); } - await Image.writeToCacheAsync(image, cacheKey); + await Image.writeToCacheAsync(decoded, cacheKey); const path = await Image.getCachePathAsync(cacheKey); if (!path) throw new Error("Project icon thumbnail was not written."); const file = new File(path.startsWith("file:") ? path : `file://${path}`); try { if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; const base64 = await file.base64(); - // SDWebImage chooses JPEG for opaque images and PNG for transparency. + // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. const mimeType = base64.startsWith("/9j/") ? "image/jpeg" : base64.startsWith("iVBORw0KGgo") @@ -38,25 +63,41 @@ export async function createProjectFaviconThumbnail(url: string, signal: AbortSi file.delete(); } } finally { - image.release(); + decoded.release(); } } throw new Error("Project icon thumbnail exceeds the cache limit."); } -async function cacheFile() { - const { File, Paths } = await import("expo-file-system"); - return new File(Paths.cache, "t3-project-favicons-v1.json"); -} - +/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ export const projectFaviconCache = createProjectFaviconCache({ - async read() { - const file = await cacheFile(); - return file.exists ? file.text() : null; - }, - async write(json) { - const file = await cacheFile(); - file.write(json); + storage: { + list: () => + runDatabase((database) => + database.listCache(CACHE_KIND).pipe( + Effect.map((payloads) => + payloads.flatMap((payload): Array => { + try { + return [JSON.parse(payload)]; + } catch { + return []; + } + }), + ), + ), + ), + put: (key, entry: ProjectFaviconEntry) => + runDatabase((database) => + database.saveCache( + entry.environmentId, + CACHE_KIND, + key, + CACHE_SCHEMA_VERSION, + JSON.stringify(entry), + ), + ), + remove: (key, entry) => + runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), }, - thumbnail: createProjectFaviconThumbnail, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), }); diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 71876932b789..aca830f24c71 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [ "connection-vcs-refs", ] as const; -export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); +export const ClientCacheKind = Schema.Literals([ + "shell", + "thread", + "server-config", + "vcs-refs", + "project-favicon", +]); export type ClientCacheKind = typeof ClientCacheKind.Type; export interface ClientCacheSummaryRow { @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ "open", "migrate", "load-cache", + "list-cache", "save-cache", "remove-cache", "clear-cache-kind", @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect, MobileDatabaseError>; + readonly listCache: ( + kind: ClientCacheKind, + ) => Effect.Effect, MobileDatabaseError>; readonly saveCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { catch: databaseError("load-cache"), }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), ), + listCache: Effect.fn("MobileDatabase.listCache")((kind) => + Effect.tryPromise({ + try: () => + database.getAllAsync<{ readonly payload: string }>( + "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at", + kind, + ), + catch: databaseError("list-cache"), + }).pipe(Effect.map((rows) => rows.map((row) => row.payload))), + ), saveCache: Effect.fn("MobileDatabase.saveCache")( (environmentId, kind, cacheKey, schemaVersion, payload) => Effect.tryPromise({ @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] const fail = Effect.fail(error); return MobileDatabase.of({ loadCache: () => fail, + listCache: () => fail, saveCache: () => fail, removeCache: () => fail, clearCacheKind: () => fail, diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index 3912857b5751..c210c54f2fdd 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => - MobileDatabase.pipe( + Effect.promise(() => + scope.type === "all" + ? projectFaviconCache.clearAll() + : projectFaviconCache.clearEnvironment(scope.environmentId), + ).pipe( + Effect.andThen(MobileDatabase), Effect.flatMap((database) => scope.type === "all" ? database.clearAllCaches diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts index 2ffd9cc45672..e6fcc817b969 100644 --- a/apps/web/src/assets/projectFaviconCache.ts +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -1,80 +1,85 @@ import { createProjectFaviconCache, + createProjectFaviconImageLoader, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, PROJECT_FAVICON_THUMBNAIL_SIZE, } from "@t3tools/client-runtime/project-favicon-cache"; +const DATABASE_NAME = "t3code:project-favicons"; +const DATABASE_VERSION = 2; +const STORE_NAME = "images"; let database: Promise | undefined; function openDatabase() { return (database ??= new Promise((resolve, reject) => { - const request = indexedDB.open("t3code:project-favicons", 1); - request.addEventListener("upgradeneeded", () => request.result.createObjectStore("thumbnails")); + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + for (const name of request.result.objectStoreNames) { + if (name !== STORE_NAME) request.result.deleteObjectStore(name); + } + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }); request.addEventListener("success", () => resolve(request.result)); request.addEventListener("error", () => reject(request.error)); request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); })); } -export async function createProjectFaviconThumbnail(url: string, signal: AbortSignal) { - const image = new Image(); - image.crossOrigin = "anonymous"; +function completed(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); +} + +async function withStore( + mode: IDBTransactionMode, + use: (store: IDBObjectStore) => IDBRequest | void, +) { + const transaction = (await openDatabase()).transaction(STORE_NAME, mode); + const request = use(transaction.objectStore(STORE_NAME)); + await completed(transaction); + return request?.result; +} + +/** Rasterizes a bitmap that is too large to inline, retrying at half size. */ +export async function downscaleProjectFavicon( + image: { readonly mimeType: string; readonly bytes: Uint8Array }, + signal: AbortSignal, +) { + const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); try { - await new Promise((resolve, reject) => { - const abort = () => finish(signal.reason); - const loaded = () => finish(); - const failed = () => finish(new Error("Could not decode project icon.")); - const finish = (error?: unknown) => { - signal.removeEventListener("abort", abort); - image.removeEventListener("load", loaded); - image.removeEventListener("error", failed); - if (error) reject(error); - else resolve(); - }; - signal.addEventListener("abort", abort, { once: true }); - image.addEventListener("load", loaded, { once: true }); - image.addEventListener("error", failed, { once: true }); - if (signal.aborted) finish(signal.reason); - else image.src = url; - }); signal.throwIfAborted(); const canvas = document.createElement("canvas"); for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { - const scale = Math.min(1, size / image.naturalWidth, size / image.naturalHeight); - canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); - canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); + const scale = Math.min(1, size / bitmap.width, size / bitmap.height); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); const context = canvas.getContext("2d"); if (!context) throw new Error("Canvas is unavailable."); - context.drawImage(image, 0, 0, canvas.width, canvas.height); + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); const dataUrl = canvas.toDataURL("image/webp", 0.85); if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; } throw new Error("Project icon thumbnail exceeds the cache limit."); } finally { - image.src = ""; + bitmap.close(); } } export const projectFaviconCache = createProjectFaviconCache({ - async read() { - const db = await openDatabase(); - return new Promise((resolve, reject) => { - const request = db.transaction("thumbnails", "readonly").objectStore("thumbnails").get("v1"); - request.addEventListener("success", () => - resolve(typeof request.result === "string" ? request.result : null), - ); - request.addEventListener("error", () => reject(request.error)); - }); - }, - async write(json) { - const db = await openDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction("thumbnails", "readwrite"); - transaction.objectStore("thumbnails").put(json, "v1"); - transaction.addEventListener("complete", () => resolve()); - transaction.addEventListener("abort", () => reject(transaction.error)); - transaction.addEventListener("error", () => reject(transaction.error)); - }); + storage: { + list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], + put: async (key, entry) => { + await withStore("readwrite", (store) => store.put(entry, key)); + }, + remove: async (key) => { + await withStore("readwrite", (store) => store.delete(key)); + }, }, - thumbnail: createProjectFaviconThumbnail, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), }); diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts index 07e19293b39b..28e1831583b1 100644 --- a/packages/client-runtime/src/projectFaviconCache.test.ts +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -3,9 +3,12 @@ import { EnvironmentId } from "@t3tools/contracts"; import { createProjectFaviconCache, + createProjectFaviconImageLoader, PROJECT_FAVICON_CACHE_MAX_BYTES, PROJECT_FAVICON_CACHE_MAX_ENTRIES, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + type ProjectFaviconEntry, + type ProjectFaviconStorage, } from "./projectFaviconCache.ts"; const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; @@ -23,67 +26,69 @@ function deferred() { } function fixture() { - let persisted: string | null = null; - const thumbnail = vi.fn(async () => image); - const storage = { - read: async () => persisted, - write: async (json: string) => { - persisted = json; + const records = new Map(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + records.delete(key); }, - thumbnail, }; return { storage, - thumbnail, - cache: createProjectFaviconCache(storage), - persisted: () => JSON.parse(persisted ?? "[]") as Array<{ dataUrl: string }>, + load, + records, + cache: createProjectFaviconCache({ storage, load }), }; } describe("persistent project favicon cache", () => { it("restores image bytes in a fresh client before any remote response", async () => { - const { cache, storage, thumbnail } = fixture(); + const { cache, storage, load } = fixture(); expect(await cache.resolve(target, url, signal())).toBe(image); await cache.flush(); - const reloaded = createProjectFaviconCache(storage); + const reloaded = createProjectFaviconCache({ storage, load }); await reloaded.hydrate(); expect(reloaded.peek(target)).toBe(image); expect(await reloaded.resolve(target, null, signal())).toBe(image); - expect(thumbnail).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); }); - it("reuses the thumbnail when signed URLs or connection origins change", async () => { - const { cache, thumbnail } = fixture(); + it("reuses the image when signed URLs or connection origins change", async () => { + const { cache, load } = fixture(); await cache.resolve(target, url, signal()); expect( await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()), ).toBe(image); - expect(thumbnail).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); }); it("keeps the old image during refresh and failures, then persists its replacement", async () => { - const { cache, thumbnail, storage } = fixture(); + const { cache, load, storage } = fixture(); await cache.resolve(target, url, signal()); const next = deferred(); - thumbnail.mockImplementationOnce(() => next.promise); + load.mockImplementationOnce(() => next.promise); const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal()); expect(cache.peek(target)).toBe(image); next.resolve(replacement); expect(await refreshing).toBe(replacement); - thumbnail.mockRejectedValueOnce(new Error("offline")); + load.mockRejectedValueOnce(new Error("offline")); expect(await cache.resolve(target, url, signal())).toBe(replacement); await cache.flush(); - expect(await createProjectFaviconCache(storage).resolve(target, null, signal())).toBe( + expect(await createProjectFaviconCache({ storage, load }).resolve(target, null, signal())).toBe( replacement, ); }); - it("persists confirmed removal and ignores an aborted older thumbnail", async () => { - const { cache, thumbnail, storage } = fixture(); + it("persists confirmed removal and ignores an aborted older download", async () => { + const { cache, load, storage } = fixture(); await cache.resolve(target, url, signal()); const next = deferred(); const started = deferred(); - thumbnail.mockImplementationOnce(() => { + load.mockImplementationOnce(() => { started.resolve(); return next.promise; }); @@ -101,7 +106,9 @@ describe("persistent project favicon cache", () => { next.resolve(replacement); await pending; await cache.flush(); - expect(await createProjectFaviconCache(storage).resolve(target, null, signal())).toBeNull(); + expect( + await createProjectFaviconCache({ storage, load }).resolve(target, null, signal()), + ).toBeNull(); }); it("isolates environments, workspaces, and icon selections", async () => { @@ -113,62 +120,151 @@ describe("persistent project favicon cache", () => { expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull(); }); - it("does not restore images for an environment removed during a download", async () => { - const { cache, thumbnail } = fixture(); - const next = deferred(); - const started = deferred(); - thumbnail.mockImplementationOnce(() => { - started.resolve(); - return next.promise; - }); - const pending = cache.resolve(target, url, signal()); - await started.promise; - await cache.clearEnvironment(target.environmentId); - next.resolve(image); - await pending; - expect(cache.peek(target)).toBeNull(); - }); + it.each([ + { + scope: "one environment", + clear: (cache: ReturnType["cache"]) => + cache.clearEnvironment(target.environmentId), + remaining: 1, + }, + { + scope: "every environment", + clear: (cache: ReturnType["cache"]) => cache.clearAll(), + remaining: 0, + }, + ])( + "does not restore images for $scope removed during a download", + async ({ clear, remaining }) => { + const { cache, load, records } = fixture(); + const other = { ...target, environmentId: EnvironmentId.make("other") }; + await cache.resolve(other, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const pending = cache.resolve(target, url, signal()); + await started.promise; + await clear(cache); + next.resolve(image); + await pending; + await cache.flush(); + expect(cache.peek(target)).toBeNull(); + expect(records.size).toBe(remaining); + }, + ); - it("bounds individual thumbnails, total thumbnail bytes, and entry count", async () => { - const { cache, thumbnail, persisted } = fixture(); - thumbnail.mockResolvedValueOnce( + it("bounds individual images, total bytes, and entry count in storage", async () => { + const { cache, load, records } = fixture(); + load.mockResolvedValueOnce( `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, ); expect(await cache.resolve(target, url, signal())).toBe(url); expect(cache.peek(target)).toBeNull(); const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`; - thumbnail.mockResolvedValue(large); + load.mockResolvedValue(large); for (let i = 0; i < 40; i++) { await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal()); } await cache.flush(); expect( - persisted().reduce((total, entry) => total + entry.dataUrl.length, 0), + [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0), ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES); expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull(); expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large); - thumbnail.mockResolvedValue(image); + load.mockResolvedValue(image); for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) { await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal()); } await cache.flush(); - expect(persisted()).toHaveLength(PROJECT_FAVICON_CACHE_MAX_ENTRIES); + expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES); expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull(); }); - it.each(["corrupt JSON", "storage unavailable"])("tolerates %s", async (failure) => { - const cache = createProjectFaviconCache({ - read: async () => { - if (failure === "storage unavailable") throw new Error(failure); - return "invalid JSON"; + it("skips corrupt records and tolerates unavailable storage", async () => { + const corrupt = createProjectFaviconCache({ + storage: { + list: async () => [ + { ...target, faviconPath: null, revision: "r", dataUrl: image }, + { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" }, + "garbage", + ], + put: async () => {}, + remove: async () => {}, }, - write: async () => { - throw new Error("quota exceeded"); + load: async () => replacement, + }); + await corrupt.hydrate(); + expect(corrupt.peek(target)).toBe(image); + expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull(); + + const unavailable = createProjectFaviconCache({ + storage: { + list: async () => { + throw new Error("storage unavailable"); + }, + put: async () => { + throw new Error("quota exceeded"); + }, + remove: async () => { + throw new Error("quota exceeded"); + }, }, - thumbnail: async () => image, + load: async () => image, }); - expect(await cache.resolve(target, url, signal())).toBe(image); - await cache.flush(); - expect(cache.peek(target)).toBe(image); + expect(await unavailable.resolve(target, url, signal())).toBe(image); + await unavailable.flush(); + expect(unavailable.peek(target)).toBe(image); + }); +}); + +describe("project favicon image loader", () => { + const svg = + ''; + const svgBase64 = btoa(svg); + + function loader(response: Response, downscale = vi.fn(async () => replacement)) { + return { + downscale, + load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }), + }; + } + + it("inlines small icons exactly as served without rasterizing", async () => { + const { load, downscale } = loader( + new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8" } }), + ); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + expect(downscale).not.toHaveBeenCalled(); + }); + + it("falls back to the file extension when the response has no image type", async () => { + const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } })); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + }); + + it("downscales large bitmaps and refuses large vector icons", async () => { + const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH); + const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } })); + expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe( + replacement, + ); + expect(bitmap.downscale).toHaveBeenCalledWith( + expect.objectContaining({ mimeType: "image/png", bytes }), + expect.any(AbortSignal), + ); + const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } })); + await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit"); + expect(vector.downscale).not.toHaveBeenCalled(); + }); + + it("rejects failed responses and non-image payloads", async () => { + const failed = loader(new Response("nope", { status: 404 })); + await expect(failed.load(url, signal())).rejects.toThrow("404"); + const html = loader(new Response("", { headers: { "content-type": "text/html" } })); + await expect( + html.load("https://remote.test/api/assets/t/v1-favicon", signal()), + ).rejects.toThrow("no image type"); }); }); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts index 3bb356f8e96f..221e99d02b4d 100644 --- a/packages/client-runtime/src/projectFaviconCache.ts +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -1,9 +1,12 @@ import { EnvironmentId } from "@t3tools/contracts"; +import { mediaMimeType } from "@t3tools/shared/filePreview"; import { getProjectFaviconCacheKey, getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; @@ -17,35 +20,97 @@ export interface ProjectFaviconTarget { readonly faviconPath?: string | null | undefined; } -const Thumbnail = Schema.String.check( +const ImageDataUrl = Schema.String.check( Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH), - Schema.isPattern(/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/), + Schema.isPattern( + /^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/, + ), ); const Entry = Schema.Struct({ environmentId: EnvironmentId, cwd: Schema.String, faviconPath: Schema.NullOr(Schema.String), revision: Schema.String, - dataUrl: Thumbnail, + dataUrl: ImageDataUrl, }); -const decodeEntries = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Array(Entry))); -const isThumbnail = Schema.is(Thumbnail); +export type ProjectFaviconEntry = typeof Entry.Type; +const decodeEntry = Schema.decodeUnknownOption(Entry); +const isImageDataUrl = Schema.is(ImageDataUrl); function keyFor(target: ProjectFaviconTarget) { return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath); } +export interface ProjectFaviconStorage { + /** Every persisted record; entries that fail validation are ignored. */ + readonly list: () => Promise>; + readonly put: (key: string, entry: ProjectFaviconEntry) => Promise; + readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise; +} + +/** + * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs + * and small bitmaps are stored exactly as served. Larger bitmaps go through the + * platform downscaler; larger SVGs stay remote because rasterizing them without + * intrinsic dimensions is unreliable. + */ +export function createProjectFaviconImageLoader(input: { + readonly fetch?: typeof fetch; + readonly downscale: ( + image: { + readonly url: string; + readonly mimeType: string; + readonly bytes: Uint8Array; + }, + signal: AbortSignal, + ) => Promise; +}) { + const fetchImpl = input.fetch ?? globalThis.fetch; + return async (url: string, signal: AbortSignal): Promise => { + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`Project icon request failed with ${response.status}.`); + const contentType = response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url); + if (!mimeType) throw new Error("Project icon has no image type."); + const bytes = new Uint8Array(await response.arrayBuffer()); + signal.throwIfAborted(); + const dataUrl = `data:${mimeType};base64,${Encoding.encodeBase64(bytes)}`; + if (isImageDataUrl(dataUrl)) return dataUrl; + if (mimeType === "image/svg+xml") throw new Error("Project icon exceeds the cache limit."); + return input.downscale({ url, mimeType, bytes }, signal); + }; +} + /** Stores small, self-contained images so startup never needs an old signed URL. */ export function createProjectFaviconCache(input: { - readonly read: () => Promise; - readonly write: (json: string) => Promise; - readonly thumbnail: (url: string, signal: AbortSignal) => Promise; + readonly storage: ProjectFaviconStorage; + readonly load: (url: string, signal: AbortSignal) => Promise; }) { - const entries = new Map(); + const entries = new Map(); const environmentRevisions = new Map(); + let generation = 0; let hydration: Promise | undefined; - let writer: Promise | undefined; - let dirty = false; + const pending = new Set>(); + + const persist = (operation: () => Promise) => { + const task: Promise = operation() + .catch(() => { + // Keep the in-memory image if local storage is full or unavailable. + }) + .finally(() => pending.delete(task)); + pending.add(task); + }; + + const remove = (key: string) => { + const entry = entries.get(key); + if (!entry) return; + entries.delete(key); + persist(() => input.storage.remove(key, entry)); + }; const trim = () => { let bytes = 0; @@ -57,39 +122,23 @@ export function createProjectFaviconCache(input: { const oldest = entries.entries().next().value; if (!oldest) break; bytes -= oldest[1].dataUrl.length; - entries.delete(oldest[0]); + remove(oldest[0]); } }; const hydrate = () => (hydration ??= (async () => { try { - const json = await input.read(); - if (json === null) return; - for (const entry of decodeEntries(json)) entries.set(keyFor(entry), entry); + for (const record of await input.storage.list()) { + const entry = decodeEntry(record); + if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value); + } trim(); } catch { // A missing, corrupt, or unavailable cache must not prevent startup. } })()); - const persist = () => { - dirty = true; - return (writer ??= (async () => { - while (dirty) { - dirty = false; - try { - await input.write(JSON.stringify([...entries.values()])); - } catch { - // Keep the in-memory thumbnail if local storage is full or unavailable. - } - } - })().finally(() => { - writer = undefined; - if (dirty) void persist(); - })); - }; - const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; const resolve = async ( @@ -97,12 +146,13 @@ export function createProjectFaviconCache(input: { url: string | null, signal: AbortSignal, ): Promise => { - const environmentRevision = environmentRevisions.get(target.environmentId) ?? 0; + const startGeneration = generation; + const startRevision = environmentRevisions.get(target.environmentId) ?? 0; await hydrate(); if (signal.aborted || url === null) return peek(target); const key = keyFor(target); if (isProjectFaviconFallbackUrl(url)) { - if (entries.delete(key)) void persist(); + remove(key); return null; } const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url); @@ -113,22 +163,25 @@ export function createProjectFaviconCache(input: { if (cached.revision === revision) return cached.dataUrl; } try { - const dataUrl = await input.thumbnail(url, signal); + const dataUrl = await input.load(url, signal); if ( signal.aborted || - environmentRevision !== (environmentRevisions.get(target.environmentId) ?? 0) - ) + startGeneration !== generation || + startRevision !== (environmentRevisions.get(target.environmentId) ?? 0) + ) { return peek(target); - if (isThumbnail(dataUrl)) { - entries.set(key, { + } + if (isImageDataUrl(dataUrl)) { + const entry = { environmentId: target.environmentId, cwd: target.cwd, faviconPath: target.faviconPath || null, revision, dataUrl, - }); + }; + entries.set(key, entry); + persist(() => input.storage.put(key, entry)); trim(); - void persist(); return dataUrl; } } catch { @@ -137,21 +190,28 @@ export function createProjectFaviconCache(input: { return peek(target) ?? url; }; + const flush = async () => { + await Promise.all(pending); + }; + + const clear = async (environmentId?: EnvironmentId) => { + if (environmentId === undefined) generation += 1; + else + environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); + await hydrate(); + for (const [key, entry] of entries) { + if (environmentId === undefined || entry.environmentId === environmentId) remove(key); + } + await flush(); + }; + return { hydrate, peek, resolve, - async clearEnvironment(environmentId: EnvironmentId) { - environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); - await hydrate(); - for (const [key, entry] of entries) { - if (entry.environmentId === environmentId) entries.delete(key); - } - await persist(); - }, - async flush() { - await writer; - }, + clearEnvironment: (environmentId: EnvironmentId) => clear(environmentId), + clearAll: () => clear(), + flush, }; } diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index b366367aca9a..1cbc970df928 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -127,23 +127,25 @@ describe("project favicon URL cache", () => { it("renders a persisted thumbnail immediately in a fresh registry and refreshes it remotely", async () => { const image = "data:image/png;base64,aWNvbg=="; const replacement = "data:image/png;base64,bmV3"; - let stored: string | null = null; + const records = new Map(); const storage = { - read: async () => stored, - write: async (json: string) => { - stored = json; + list: async () => [...records.values()], + put: async (key: string, entry: unknown) => { + records.set(key, entry); + }, + remove: async (key: string) => { + records.delete(key); }, - thumbnail: async () => image, }; const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; - const previousCache = createProjectFaviconCache(storage); + const previousCache = createProjectFaviconCache({ storage, load: async () => image }); await previousCache.resolve( target, "https://remote.test/api/assets/old/v1-icon.png", new AbortController().signal, ); await previousCache.flush(); - const cache = createProjectFaviconCache({ ...storage, thumbnail: async () => replacement }); + const cache = createProjectFaviconCache({ storage, load: async () => replacement }); await cache.hydrate(); const registry = AtomRegistry.make(); const result = Atom.make>( From 3069ab5b662dd8aa3257c349778afee4fa30cf6f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:49:10 -0700 Subject: [PATCH 5/6] fix(clients): bound project icon downloads and stop re-entering the mobile runtime Cap icon responses at 4 MiB while streaming so an oversized image is dropped before it is materialized. On mobile, the environment cache store hands its database to the favicon cache instead of the cache calling back into the application runtime from inside a fiber that already runs on it. Co-Authored-By: Claude Fable 5 --- .../src/connection/environment-cache-store.ts | 3 +- apps/mobile/src/lib/projectFaviconCache.ts | 23 ++++++++---- .../src/projectFaviconCache.test.ts | 24 +++++++++++++ .../client-runtime/src/projectFaviconCache.ts | 35 ++++++++++++++++++- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 846df793c285..ccf4945b3bef 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,7 +15,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; -import { projectFaviconCache } from "../lib/projectFaviconCache"; +import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -116,6 +116,7 @@ function loadDecodedCache(input: { export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { const database = yield* MobileDatabase.MobileDatabase; + attachProjectFaviconDatabase(database); return EnvironmentCacheStore.of({ loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => loadDecodedCache({ diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconCache.ts index ac8ed6b4775d..26a6d848d11d 100644 --- a/apps/mobile/src/lib/projectFaviconCache.ts +++ b/apps/mobile/src/lib/projectFaviconCache.ts @@ -12,14 +12,23 @@ import * as MobileDatabase from "../persistence/mobile-database"; const CACHE_KIND = "project-favicon"; const CACHE_SCHEMA_VERSION = 1; -// The runtime's persistence layer owns the cache store that hydrates this module, so -// it is loaded on first use rather than at import time. -const runDatabase = async ( +let database: MobileDatabase.MobileDatabase["Service"] | undefined; + +/** + * The cache is a module singleton because the favicon atom family holds it outside + * any Effect runtime. Its rows live in `client_cache`, so the environment cache store + * hands over the database it already owns instead of the cache re-entering the runtime. + */ +export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { + database = service; +} + +const runDatabase = ( use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, -) => { - const { runtime } = await import("./runtime"); - return runtime.runPromise(MobileDatabase.MobileDatabase.pipe(Effect.flatMap(use))); -}; +) => + database + ? Effect.runPromise(use(database)) + : Promise.reject(new Error("Project icon storage is not attached.")); /** * Rasterizes a bitmap that is too large to inline. The native decoder writes the diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts index 28e1831583b1..80ac2ff854c8 100644 --- a/packages/client-runtime/src/projectFaviconCache.test.ts +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -7,6 +7,7 @@ import { PROJECT_FAVICON_CACHE_MAX_BYTES, PROJECT_FAVICON_CACHE_MAX_ENTRIES, PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_MAX_SOURCE_BYTES, type ProjectFaviconEntry, type ProjectFaviconStorage, } from "./projectFaviconCache.ts"; @@ -259,6 +260,29 @@ describe("project favicon image loader", () => { expect(vector.downscale).not.toHaveBeenCalled(); }); + it("stops reading a response that exceeds the source limit", async () => { + let pulled = 0; + const chunk = new Uint8Array(1024 * 1024); + const stream = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(chunk); + }, + }); + const { load, downscale } = loader( + new Response(stream, { headers: { "content-type": "image/png" } }), + ); + await expect(load(url, signal())).rejects.toThrow("too large"); + expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); + expect(downscale).not.toHaveBeenCalled(); + const declared = loader( + new Response("x", { + headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, + }), + ); + await expect(declared.load(url, signal())).rejects.toThrow("too large"); + }); + it("rejects failed responses and non-image payloads", async () => { const failed = loader(new Response("nope", { status: 404 })); await expect(failed.load(url, signal())).rejects.toThrow("404"); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts index 221e99d02b4d..26fda8c016a2 100644 --- a/packages/client-runtime/src/projectFaviconCache.ts +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -11,6 +11,8 @@ import * as Schema from "effect/Schema"; export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; +/** Larger sources are not worth decoding for an icon and are left to the remote URL. */ +export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024; export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; @@ -48,6 +50,37 @@ export interface ProjectFaviconStorage { readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise; } +async function readBounded(response: Response, maxBytes: number) { + const declared = Number(response.headers.get("content-length")); + if (declared > maxBytes) throw new Error("Project icon is too large to decode."); + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); + return bytes; + } + const reader = response.body.getReader(); + const chunks: Array = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw new Error("Project icon is too large to decode."); + chunks.push(value); + } + } finally { + reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + /** * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs * and small bitmaps are stored exactly as served. Larger bitmaps go through the @@ -76,7 +109,7 @@ export function createProjectFaviconImageLoader(input: { .toLowerCase(); const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url); if (!mimeType) throw new Error("Project icon has no image type."); - const bytes = new Uint8Array(await response.arrayBuffer()); + const bytes = await readBounded(response, PROJECT_FAVICON_MAX_SOURCE_BYTES); signal.throwIfAborted(); const dataUrl = `data:${mimeType};base64,${Encoding.encodeBase64(bytes)}`; if (isImageDataUrl(dataUrl)) return dataUrl; From dd5784464d383386e30417cf055eb4bf84b6e2b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:57:19 -0700 Subject: [PATCH 6/6] fix(clients): hold icon downloads while their environment cache clears A resolve that started after the revision bump but before the clear finished could persist its result once the deletes had run. Downloads now wait for an in-flight clear before capturing the revision they compare against. Co-Authored-By: Claude Fable 5 --- .../src/projectFaviconCache.test.ts | 29 +++++++++++++++++++ .../client-runtime/src/projectFaviconCache.ts | 22 ++++++++++---- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts index 80ac2ff854c8..980ccf495165 100644 --- a/packages/client-runtime/src/projectFaviconCache.test.ts +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -156,6 +156,35 @@ describe("persistent project favicon cache", () => { }, ); + it("discards a download that starts while the environment is being cleared", async () => { + const records = new Map(); + const removal = deferred(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + await removal.promise; + records.delete(key); + }, + }; + const cache = createProjectFaviconCache({ storage, load }); + await cache.resolve(target, url, signal()); + await cache.flush(); + const clearing = cache.clearEnvironment(target.environmentId); + await Promise.resolve(); + const late = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + removal.resolve(); + await clearing; + expect(records.size).toBe(0); + expect(cache.peek(target)).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + expect(await late).toBe(image); + expect(load).toHaveBeenCalledTimes(2); + }); + it("bounds individual images, total bytes, and entry count in storage", async () => { const { cache, load, records } = fixture(); load.mockResolvedValueOnce( diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts index 26fda8c016a2..53fd58372190 100644 --- a/packages/client-runtime/src/projectFaviconCache.ts +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -127,6 +127,7 @@ export function createProjectFaviconCache(input: { const environmentRevisions = new Map(); let generation = 0; let hydration: Promise | undefined; + let clearing: Promise | undefined; const pending = new Set>(); const persist = (operation: () => Promise) => { @@ -179,6 +180,7 @@ export function createProjectFaviconCache(input: { url: string | null, signal: AbortSignal, ): Promise => { + await clearing; const startGeneration = generation; const startRevision = environmentRevisions.get(target.environmentId) ?? 0; await hydrate(); @@ -227,15 +229,25 @@ export function createProjectFaviconCache(input: { await Promise.all(pending); }; + // A download that started before the clear sees the revision change and is discarded; + // one that starts during the clear waits for it, so it cannot repopulate storage. const clear = async (environmentId?: EnvironmentId) => { if (environmentId === undefined) generation += 1; else environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); - await hydrate(); - for (const [key, entry] of entries) { - if (environmentId === undefined || entry.environmentId === environmentId) remove(key); - } - await flush(); + const previous = clearing; + const task = (async () => { + await previous; + await hydrate(); + for (const [key, entry] of entries) { + if (environmentId === undefined || entry.environmentId === environmentId) remove(key); + } + await flush(); + })().finally(() => { + if (clearing === task) clearing = undefined; + }); + clearing = task; + await task; }; return {