Skip to content

Commit eee0557

Browse files
authored
fix(clients): persist project icons across reloads and reconnects (#10138)
1 parent 050690d commit eee0557

20 files changed

Lines changed: 1207 additions & 62 deletions

‎apps/mobile/src/components/ProjectFavicon.tsx‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ import { View } from "react-native";
55
import type { EnvironmentId } from "@t3tools/contracts";
66
import {
77
getProjectFaviconCacheKey,
8+
getProjectFaviconResourceKey,
89
isProjectFaviconFallbackUrl,
910
} from "@t3tools/shared/projectFavicon";
10-
import { useAssetUrl } from "../state/assets";
11+
import { useAtomValue } from "@effect/atom-react";
12+
import { Atom } from "effect/unstable/reactivity";
13+
import { projectFaviconUrlAtom } from "../state/assets";
14+
1115
import {
1216
beginProjectFaviconRequest,
1317
createProjectFaviconRequest,
@@ -16,6 +20,8 @@ import {
1620
markProjectFaviconLoaded,
1721
} from "./projectFaviconCache";
1822

23+
const EMPTY_FAVICON_URL = Atom.make<string | null>(null);
24+
1925
/* ─── Component ──────────────────────────────────────────────────────── */
2026
export function ProjectFavicon(props: {
2127
readonly environmentId: EnvironmentId;
@@ -26,20 +32,23 @@ export function ProjectFavicon(props: {
2632
readonly faviconPath?: string | null;
2733
}) {
2834
const size = props.size ?? 42;
29-
const faviconUrl = useAssetUrl(
30-
props.environmentId,
31-
props.workspaceRoot === null || props.workspaceRoot === undefined
32-
? null
33-
: {
34-
_tag: "project-favicon",
35+
const faviconUrl = useAtomValue(
36+
props.workspaceRoot == null
37+
? EMPTY_FAVICON_URL
38+
: projectFaviconUrlAtom({
39+
environmentId: props.environmentId,
3540
cwd: props.workspaceRoot,
36-
...(props.faviconPath ? { path: props.faviconPath } : {}),
37-
},
41+
faviconPath: props.faviconPath,
42+
}),
3843
);
3944
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.
4047
const cacheKey =
4148
renderableFaviconUrl && props.workspaceRoot
42-
? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
49+
? renderableFaviconUrl.startsWith("data:")
50+
? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath)
51+
: getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl)
4352
: null;
4453

4554
return (
@@ -75,7 +84,9 @@ function ProjectFaviconImage(props: {
7584
}, [faviconRequest]);
7685

7786
const [status, setStatus] = useState<"loading" | "loaded" | "error">(() =>
78-
hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading",
87+
props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey)
88+
? "loaded"
89+
: "loading",
7990
);
8091

8192
const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest;
@@ -104,11 +115,12 @@ function ProjectFaviconImage(props: {
104115
{requestIsActive ? (
105116
<Image
106117
key={faviconRequest.faviconUrl}
107-
source={{
108-
uri: faviconRequest.faviconUrl,
109-
cacheKey: faviconRequest.cacheKey,
110-
}}
111-
cachePolicy="memory-disk"
118+
source={
119+
faviconRequest.faviconUrl.startsWith("data:")
120+
? { uri: faviconRequest.faviconUrl }
121+
: { uri: faviconRequest.faviconUrl, cacheKey: faviconRequest.cacheKey }
122+
}
123+
cachePolicy={faviconRequest.faviconUrl.startsWith("data:") ? "memory" : "memory-disk"}
112124
recyclingKey={faviconRequest.cacheKey}
113125
accessibilityLabel={`${props.projectTitle} favicon`}
114126
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: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as Option from "effect/Option";
1515
import * as Schema from "effect/Schema";
1616

1717
import * as MobileDatabase from "../persistence/mobile-database";
18+
import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache";
1819

1920
const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
2021
// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
@@ -115,6 +116,7 @@ function loadDecodedCache<A, B>(input: {
115116

116117
export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
117118
const database = yield* MobileDatabase.MobileDatabase;
119+
attachProjectFaviconDatabase(database);
118120
return EnvironmentCacheStore.of({
119121
loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
120122
loadDecodedCache({
@@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
126128
decode: decodeStoredShellSnapshot,
127129
select: (stored) =>
128130
stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
129-
}),
131+
}).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))),
130132
),
131133
saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
132134
const payload = yield* encodeStoredShellSnapshot({
@@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
237239
.pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),
238240
),
239241
clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>
240-
database
241-
.clearEnvironmentCache(environmentId)
242-
.pipe(Effect.mapError(mapDatabaseError("clear-environment"))),
242+
Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe(
243+
Effect.andThen(database.clearEnvironmentCache(environmentId)),
244+
Effect.mapError(mapDatabaseError("clear-environment")),
245+
),
243246
),
244247
});
245248
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache";
3+
4+
const native = vi.hoisted(() => ({
5+
load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({
6+
width: options.maxWidth,
7+
height: options.maxHeight,
8+
release: vi.fn(),
9+
})),
10+
write: vi.fn(async () => {}),
11+
path: vi.fn(async () => "/cache/thumbnail"),
12+
read: vi.fn(),
13+
remove: vi.fn(),
14+
}));
15+
vi.mock("expo-image", () => ({
16+
Image: {
17+
loadAsync: native.load,
18+
writeToCacheAsync: native.write,
19+
getCachePathAsync: native.path,
20+
},
21+
}));
22+
vi.mock("expo-file-system", () => ({
23+
File: class {
24+
size = 24_000;
25+
base64 = native.read;
26+
delete = native.remove;
27+
},
28+
}));
29+
30+
import { downscaleProjectFavicon } from "./projectFaviconCache";
31+
32+
const png = "iVBORw0KGgoAAAAA";
33+
const image = { url: "https://remote/icon.png" };
34+
35+
beforeEach(() => {
36+
vi.clearAllMocks();
37+
native.read.mockReset().mockResolvedValue(png);
38+
native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({
39+
width: maxWidth,
40+
height: maxWidth,
41+
release: vi.fn(),
42+
}));
43+
});
44+
45+
describe("mobile project icon thumbnails", () => {
46+
it("reduces an oversized encoding and deletes temporary thumbnail files", async () => {
47+
native.read.mockResolvedValueOnce(
48+
`iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`,
49+
);
50+
const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal);
51+
expect(thumbnail).toBe(`data:image/png;base64,${png}`);
52+
expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]);
53+
expect(native.remove).toHaveBeenCalledTimes(2);
54+
for (const call of native.load.mock.results)
55+
expect((await call.value).release).toHaveBeenCalledOnce();
56+
});
57+
58+
it("releases a decoded image when its request was canceled", async () => {
59+
const controller = new AbortController();
60+
const release = vi.fn();
61+
native.load.mockImplementationOnce(async () => {
62+
controller.abort();
63+
return { width: 96, height: 96, release };
64+
});
65+
await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow();
66+
expect(release).toHaveBeenCalledOnce();
67+
expect(native.write).not.toHaveBeenCalled();
68+
});
69+
70+
it("rejects an image the native decoder did not downsize", async () => {
71+
const release = vi.fn();
72+
native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release });
73+
await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow(
74+
"not resized",
75+
);
76+
expect(native.write).not.toHaveBeenCalled();
77+
expect(release).toHaveBeenCalledOnce();
78+
});
79+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
createProjectFaviconCache,
3+
createProjectFaviconImageLoader,
4+
PROJECT_FAVICON_MAX_DATA_URL_LENGTH,
5+
PROJECT_FAVICON_THUMBNAIL_SIZE,
6+
type ProjectFaviconEntry,
7+
} from "@t3tools/client-runtime/project-favicon-cache";
8+
import * as Effect from "effect/Effect";
9+
10+
import * as MobileDatabase from "../persistence/mobile-database";
11+
12+
const CACHE_KIND = "project-favicon";
13+
const CACHE_SCHEMA_VERSION = 1;
14+
15+
let database: MobileDatabase.MobileDatabase["Service"] | undefined;
16+
17+
/**
18+
* The cache is a module singleton because the favicon atom family holds it outside
19+
* any Effect runtime. Its rows live in `client_cache`, so the environment cache store
20+
* hands over the database it already owns instead of the cache re-entering the runtime.
21+
*/
22+
export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) {
23+
database = service;
24+
}
25+
26+
const runDatabase = <A, E>(
27+
use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect<A, E>,
28+
) =>
29+
database
30+
? Effect.runPromise(use(database))
31+
: Promise.reject(new Error("Project icon storage is not attached."));
32+
33+
/**
34+
* Rasterizes a bitmap that is too large to inline. The native decoder writes the
35+
* downsized frame to expo-image's disk cache, which is the only encode path it
36+
* exposes; the temporary entry is removed once its bytes are read.
37+
*/
38+
export async function downscaleProjectFavicon(
39+
image: { readonly url: string },
40+
signal: AbortSignal,
41+
) {
42+
const [{ Image }, { File }] = await Promise.all([
43+
import("expo-image"),
44+
import("expo-file-system"),
45+
]);
46+
for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) {
47+
signal.throwIfAborted();
48+
const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size });
49+
const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`;
50+
try {
51+
signal.throwIfAborted();
52+
if (decoded.width > size || decoded.height > size) {
53+
throw new Error("Project icon was not resized.");
54+
}
55+
await Image.writeToCacheAsync(decoded, cacheKey);
56+
const path = await Image.getCachePathAsync(cacheKey);
57+
if (!path) throw new Error("Project icon thumbnail was not written.");
58+
const file = new File(path.startsWith("file:") ? path : `file://${path}`);
59+
try {
60+
if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue;
61+
const base64 = await file.base64();
62+
// SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG.
63+
const mimeType = base64.startsWith("/9j/")
64+
? "image/jpeg"
65+
: base64.startsWith("iVBORw0KGgo")
66+
? "image/png"
67+
: null;
68+
if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding.");
69+
const dataUrl = `data:${mimeType};base64,${base64}`;
70+
if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl;
71+
} finally {
72+
file.delete();
73+
}
74+
} finally {
75+
decoded.release();
76+
}
77+
}
78+
throw new Error("Project icon thumbnail exceeds the cache limit.");
79+
}
80+
81+
/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */
82+
export const projectFaviconCache = createProjectFaviconCache({
83+
storage: {
84+
list: () =>
85+
runDatabase((database) =>
86+
database.listCache(CACHE_KIND).pipe(
87+
Effect.map((payloads) =>
88+
payloads.flatMap((payload): Array<unknown> => {
89+
try {
90+
return [JSON.parse(payload)];
91+
} catch {
92+
return [];
93+
}
94+
}),
95+
),
96+
),
97+
),
98+
put: (key, entry: ProjectFaviconEntry) =>
99+
runDatabase((database) =>
100+
database.saveCache(
101+
entry.environmentId,
102+
CACHE_KIND,
103+
key,
104+
CACHE_SCHEMA_VERSION,
105+
JSON.stringify(entry),
106+
),
107+
),
108+
remove: (key, entry) =>
109+
runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)),
110+
},
111+
load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }),
112+
});

‎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,

0 commit comments

Comments
 (0)