Skip to content

Commit 8090a47

Browse files
fix(clients): store project icons as served instead of canvas thumbnails
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 <noreply@anthropic.com>
1 parent 176ee22 commit 8090a47

11 files changed

Lines changed: 443 additions & 205 deletions

File tree

apps/mobile/src/components/ProjectFavicon.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useLayoutEffect, useMemo, useState } from "react";
44
import { View } from "react-native";
55
import type { EnvironmentId } from "@t3tools/contracts";
66
import {
7+
getProjectFaviconCacheKey,
78
getProjectFaviconResourceKey,
89
isProjectFaviconFallbackUrl,
910
} from "@t3tools/shared/projectFavicon";
@@ -41,9 +42,13 @@ export function ProjectFavicon(props: {
4142
}),
4243
);
4344
const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl;
45+
// Inline images are self-contained; remote URLs key on their revision so signed-token
46+
// rotation reuses the disk cache while a changed icon starts from the loading state.
4447
const cacheKey =
4548
renderableFaviconUrl && props.workspaceRoot
46-
? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath)
49+
? renderableFaviconUrl.startsWith("data:")
50+
? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath)
51+
: getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
4752
: null;
4853

4954
return (
@@ -79,7 +84,7 @@ function ProjectFaviconImage(props: {
7984
}, [faviconRequest]);
8085

8186
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
82-
props.faviconUrl?.startsWith("data:image/") || hasLoadedProjectFavicon(props.cacheKey)
87+
props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey)
8388
? "loaded"
8489
: "loading",
8590
);
@@ -110,12 +115,12 @@ function ProjectFaviconImage(props: {
110115
{requestIsActive ? (
111116
<Image
112117
key={faviconRequest.faviconUrl}
113-
source={{
114-
uri: faviconRequest.faviconUrl,
115-
}}
116-
cachePolicy={
117-
faviconRequest.faviconUrl.startsWith("data:image/") ? "memory" : "memory-disk"
118+
source={
119+
faviconRequest.faviconUrl.startsWith("data:")
120+
? { uri: faviconRequest.faviconUrl }
121+
: { uri: faviconRequest.faviconUrl, cacheKey: faviconRequest.cacheKey }
118122
}
123+
cachePolicy={faviconRequest.faviconUrl.startsWith("data:") ? "memory" : "memory-disk"}
119124
recyclingKey={faviconRequest.cacheKey}
120125
accessibilityLabel={`${props.projectTitle} favicon`}
121126
style={{

apps/mobile/src/connection/environment-cache-store.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ function makeDatabase() {
3232
const database = MobileDatabase.of({
3333
loadCache: (environmentId, kind, cacheKey) =>
3434
Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))),
35+
listCache: (kind) =>
36+
Effect.sync(() =>
37+
[...values.entries()]
38+
.filter(([key]) => key.split(":")[1] === kind)
39+
.map(([, payload]) => payload),
40+
),
3541
saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) =>
3642
Effect.sync(() => {
3743
values.set(cacheId(environmentId, kind, cacheKey), payload);

apps/mobile/src/connection/environment-cache-store.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,9 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
238238
.pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),
239239
),
240240
clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>
241-
database.clearEnvironmentCache(environmentId).pipe(
241+
Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe(
242+
Effect.andThen(database.clearEnvironmentCache(environmentId)),
242243
Effect.mapError(mapDatabaseError("clear-environment")),
243-
Effect.tap(() => Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId))),
244244
),
245245
),
246246
});

apps/mobile/src/lib/projectFaviconCache.test.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ vi.mock("expo-file-system", () => ({
2727
},
2828
}));
2929

30-
import { createProjectFaviconThumbnail } from "./projectFaviconCache";
30+
import { downscaleProjectFavicon } from "./projectFaviconCache";
3131

3232
const png = "iVBORw0KGgoAAAAA";
33+
const image = { url: "https://remote/icon.png" };
3334

3435
beforeEach(() => {
3536
vi.clearAllMocks();
@@ -46,10 +47,7 @@ describe("mobile project icon thumbnails", () => {
4647
native.read.mockResolvedValueOnce(
4748
`iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`,
4849
);
49-
const thumbnail = await createProjectFaviconThumbnail(
50-
"https://remote/icon.png",
51-
new AbortController().signal,
52-
);
50+
const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal);
5351
expect(thumbnail).toBe(`data:image/png;base64,${png}`);
5452
expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]);
5553
expect(native.remove).toHaveBeenCalledTimes(2);
@@ -64,19 +62,17 @@ describe("mobile project icon thumbnails", () => {
6462
controller.abort();
6563
return { width: 96, height: 96, release };
6664
});
67-
await expect(
68-
createProjectFaviconThumbnail("https://remote/icon.png", controller.signal),
69-
).rejects.toThrow();
65+
await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow();
7066
expect(release).toHaveBeenCalledOnce();
7167
expect(native.write).not.toHaveBeenCalled();
7268
});
7369

7470
it("rejects an image the native decoder did not downsize", async () => {
7571
const release = vi.fn();
7672
native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release });
77-
await expect(
78-
createProjectFaviconThumbnail("https://remote/icon.png", new AbortController().signal),
79-
).rejects.toThrow("not resized");
73+
await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow(
74+
"not resized",
75+
);
8076
expect(native.write).not.toHaveBeenCalled();
8177
expect(release).toHaveBeenCalledOnce();
8278
});
Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,56 @@
11
import {
22
createProjectFaviconCache,
3+
createProjectFaviconImageLoader,
34
PROJECT_FAVICON_MAX_DATA_URL_LENGTH,
45
PROJECT_FAVICON_THUMBNAIL_SIZE,
6+
type ProjectFaviconEntry,
57
} from "@t3tools/client-runtime/project-favicon-cache";
8+
import * as Effect from "effect/Effect";
69

7-
export async function createProjectFaviconThumbnail(url: string, signal: AbortSignal) {
10+
import * as MobileDatabase from "../persistence/mobile-database";
11+
12+
const CACHE_KIND = "project-favicon";
13+
const CACHE_SCHEMA_VERSION = 1;
14+
15+
// The runtime's persistence layer owns the cache store that hydrates this module, so
16+
// it is loaded on first use rather than at import time.
17+
const runDatabase = async <A, E>(
18+
use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect<A, E>,
19+
) => {
20+
const { runtime } = await import("./runtime");
21+
return runtime.runPromise(MobileDatabase.MobileDatabase.pipe(Effect.flatMap(use)));
22+
};
23+
24+
/**
25+
* Rasterizes a bitmap that is too large to inline. The native decoder writes the
26+
* downsized frame to expo-image's disk cache, which is the only encode path it
27+
* exposes; the temporary entry is removed once its bytes are read.
28+
*/
29+
export async function downscaleProjectFavicon(
30+
image: { readonly url: string },
31+
signal: AbortSignal,
32+
) {
833
const [{ Image }, { File }] = await Promise.all([
934
import("expo-image"),
1035
import("expo-file-system"),
1136
]);
1237
for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) {
1338
signal.throwIfAborted();
14-
const image = await Image.loadAsync(url, { maxWidth: size, maxHeight: size });
15-
const cacheKey = `t3-favicon-thumbnail:${size}:${url}`;
39+
const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size });
40+
const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`;
1641
try {
1742
signal.throwIfAborted();
18-
if (image.width > size || image.height > size) {
43+
if (decoded.width > size || decoded.height > size) {
1944
throw new Error("Project icon was not resized.");
2045
}
21-
await Image.writeToCacheAsync(image, cacheKey);
46+
await Image.writeToCacheAsync(decoded, cacheKey);
2247
const path = await Image.getCachePathAsync(cacheKey);
2348
if (!path) throw new Error("Project icon thumbnail was not written.");
2449
const file = new File(path.startsWith("file:") ? path : `file://${path}`);
2550
try {
2651
if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue;
2752
const base64 = await file.base64();
28-
// SDWebImage chooses JPEG for opaque images and PNG for transparency.
53+
// SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG.
2954
const mimeType = base64.startsWith("/9j/")
3055
? "image/jpeg"
3156
: base64.startsWith("iVBORw0KGgo")
@@ -38,25 +63,41 @@ export async function createProjectFaviconThumbnail(url: string, signal: AbortSi
3863
file.delete();
3964
}
4065
} finally {
41-
image.release();
66+
decoded.release();
4267
}
4368
}
4469
throw new Error("Project icon thumbnail exceeds the cache limit.");
4570
}
4671

47-
async function cacheFile() {
48-
const { File, Paths } = await import("expo-file-system");
49-
return new File(Paths.cache, "t3-project-favicons-v1.json");
50-
}
51-
72+
/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */
5273
export const projectFaviconCache = createProjectFaviconCache({
53-
async read() {
54-
const file = await cacheFile();
55-
return file.exists ? file.text() : null;
56-
},
57-
async write(json) {
58-
const file = await cacheFile();
59-
file.write(json);
74+
storage: {
75+
list: () =>
76+
runDatabase((database) =>
77+
database.listCache(CACHE_KIND).pipe(
78+
Effect.map((payloads) =>
79+
payloads.flatMap((payload): Array<unknown> => {
80+
try {
81+
return [JSON.parse(payload)];
82+
} catch {
83+
return [];
84+
}
85+
}),
86+
),
87+
),
88+
),
89+
put: (key, entry: ProjectFaviconEntry) =>
90+
runDatabase((database) =>
91+
database.saveCache(
92+
entry.environmentId,
93+
CACHE_KIND,
94+
key,
95+
CACHE_SCHEMA_VERSION,
96+
JSON.stringify(entry),
97+
),
98+
),
99+
remove: (key, entry) =>
100+
runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)),
60101
},
61-
thumbnail: createProjectFaviconThumbnail,
102+
load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }),
62103
});

apps/mobile/src/persistence/mobile-database.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [
1616
"connection-vcs-refs",
1717
] as const;
1818

19-
export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]);
19+
export const ClientCacheKind = Schema.Literals([
20+
"shell",
21+
"thread",
22+
"server-config",
23+
"vcs-refs",
24+
"project-favicon",
25+
]);
2026
export type ClientCacheKind = typeof ClientCacheKind.Type;
2127

2228
export interface ClientCacheSummaryRow {
@@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([
4450
"open",
4551
"migrate",
4652
"load-cache",
53+
"list-cache",
4754
"save-cache",
4855
"remove-cache",
4956
"clear-cache-kind",
@@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service<
192199
kind: ClientCacheKind,
193200
cacheKey: string,
194201
) => Effect.Effect<Option.Option<string>, MobileDatabaseError>;
202+
readonly listCache: (
203+
kind: ClientCacheKind,
204+
) => Effect.Effect<ReadonlyArray<string>, MobileDatabaseError>;
195205
readonly saveCache: (
196206
environmentId: EnvironmentId,
197207
kind: ClientCacheKind,
@@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () {
292302
catch: databaseError("load-cache"),
293303
}).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))),
294304
),
305+
listCache: Effect.fn("MobileDatabase.listCache")((kind) =>
306+
Effect.tryPromise({
307+
try: () =>
308+
database.getAllAsync<{ readonly payload: string }>(
309+
"SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at",
310+
kind,
311+
),
312+
catch: databaseError("list-cache"),
313+
}).pipe(Effect.map((rows) => rows.map((row) => row.payload))),
314+
),
295315
saveCache: Effect.fn("MobileDatabase.saveCache")(
296316
(environmentId, kind, cacheKey, schemaVersion, payload) =>
297317
Effect.tryPromise({
@@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"]
405425
const fail = Effect.fail(error);
406426
return MobileDatabase.of({
407427
loadCache: () => fail,
428+
listCache: () => fail,
408429
saveCache: () => fail,
409430
removeCache: () => fail,
410431
clearCacheKind: () => fail,

apps/mobile/src/state/client-cache-state.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
33
import { Atom } from "effect/unstable/reactivity";
44

55
import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";
6+
import { projectFaviconCache } from "../lib/projectFaviconCache";
67
import * as Runtime from "../lib/runtime";
78

89
export interface EnvironmentClientCacheSummary {
@@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime
7172

7273
export const clearClientCacheAtom = clientCacheRuntime
7374
.fn((scope: ClientCacheClearScope, get) =>
74-
MobileDatabase.pipe(
75+
Effect.promise(() =>
76+
scope.type === "all"
77+
? projectFaviconCache.clearAll()
78+
: projectFaviconCache.clearEnvironment(scope.environmentId),
79+
).pipe(
80+
Effect.andThen(MobileDatabase),
7581
Effect.flatMap((database) =>
7682
scope.type === "all"
7783
? database.clearAllCaches

0 commit comments

Comments
 (0)