authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 17:20:02-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 17:20:02-07:00
log34096519121033ccf72cd43ea5350f9482df188a
tree8333ca417729caf474d3821162b71c62a5acdfe8
parentec28eefa0d017e2b0cb65ba4269cee1adddb0e98
signature Signed by PGP key B5690EEEBB952194

chore(mobile): name the two project favicon caches by their job (#13160)


12 files changed, 418 insertions(+), 402 deletions(-)

apps/mobile/src/components/ProjectFavicon.tsx+1-1
...@@ -18,7 +18,7 @@ import {...@@ -18,7 +18,7 @@ import {
18 hasLoadedProjectFavicon,18 hasLoadedProjectFavicon,
19 markProjectFaviconFailed,19 markProjectFaviconFailed,
20 markProjectFaviconLoaded,20 markProjectFaviconLoaded,
21} from "./projectFaviconCache";21} from "../lib/projectFaviconRequests";
2222
23const EMPTY_FAVICON_URL = Atom.make<string | null>(null);23const EMPTY_FAVICON_URL = Atom.make<string | null>(null);
2424
apps/mobile/src/components/projectFaviconCache.test.ts deleted-108
...@@ -1,108 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 beginProjectFaviconRequest,
5 createProjectFaviconRequest,
6 hasLoadedProjectFavicon,
7 markProjectFaviconFailed,
8 markProjectFaviconLoaded,
9} from "./projectFaviconCache";
10
11describe("project favicon cache", () => {
12 it("ignores callbacks from a superseded URL", () => {
13 const cacheKey = "environment-1:/workspace:v1-favicon.svg";
14 const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg";
15 const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg";
16
17 const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl);
18 const endExpiredRequest = beginProjectFaviconRequest(expiredRequest);
19 markProjectFaviconLoaded(expiredRequest);
20 const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl);
21 const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest);
22
23 expect(markProjectFaviconLoaded(expiredRequest)).toBe(false);
24 expect(markProjectFaviconFailed(expiredRequest)).toBe(false);
25 expect(hasLoadedProjectFavicon(cacheKey)).toBe(true);
26 expect(markProjectFaviconFailed(refreshedRequest)).toBe(true);
27 expect(hasLoadedProjectFavicon(cacheKey)).toBe(false);
28
29 endRefreshedRequest();
30 endExpiredRequest();
31 });
32
33 it("evicts the URL that actually failed", () => {
34 const cacheKey = "environment-1:/workspace:v2-favicon.svg";
35 const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg";
36 const request = createProjectFaviconRequest(cacheKey, faviconUrl);
37 const endRequest = beginProjectFaviconRequest(request);
38
39 markProjectFaviconLoaded(request);
40
41 expect(markProjectFaviconFailed(request)).toBe(true);
42 expect(hasLoadedProjectFavicon(cacheKey)).toBe(false);
43
44 endRequest();
45 });
46
47 it("does not supersede a request until the next request begins", () => {
48 const cacheKey = "environment-1:/workspace:v3-favicon.svg";
49 const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg";
50 const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg";
51 const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl);
52 const endCommittedRequest = beginProjectFaviconRequest(committedRequest);
53
54 createProjectFaviconRequest(cacheKey, abandonedUrl);
55
56 expect(markProjectFaviconLoaded(committedRequest)).toBe(true);
57 expect(hasLoadedProjectFavicon(cacheKey)).toBe(true);
58
59 endCommittedRequest();
60 });
61
62 it("requires a cache key before creating a URL-bearing request", () => {
63 const firstUrl = "https://environment.example/api/assets/first/favicon.svg";
64 const secondUrl = "https://environment.example/api/assets/second/favicon.svg";
65
66 expect(createProjectFaviconRequest(null, firstUrl)).toBeNull();
67 expect(createProjectFaviconRequest(null, secondUrl)).toBeNull();
68 });
69
70 it("restores the remaining active URL when a newer request ends", () => {
71 const cacheKey = "environment-1:/workspace:v4-favicon.svg";
72 const firstRequest = createProjectFaviconRequest(
73 cacheKey,
74 "https://environment.example/api/assets/first/v4-favicon.svg",
75 );
76 const secondRequest = createProjectFaviconRequest(
77 cacheKey,
78 "https://environment.example/api/assets/second/v4-favicon.svg",
79 );
80 const endFirstRequest = beginProjectFaviconRequest(firstRequest);
81 const endSecondRequest = beginProjectFaviconRequest(secondRequest);
82
83 expect(markProjectFaviconLoaded(firstRequest)).toBe(false);
84 endSecondRequest();
85 expect(markProjectFaviconLoaded(firstRequest)).toBe(true);
86 endFirstRequest();
87 expect(markProjectFaviconLoaded(firstRequest)).toBe(false);
88 });
89
90 it("bounds remembered loaded revisions", () => {
91 const firstCacheKey = "environment-1:/workspace:revision-0";
92 let lastCacheKey = firstCacheKey;
93
94 for (let revision = 0; revision < 300; revision++) {
95 lastCacheKey = `environment-1:/workspace:revision-${revision}`;
96 const request = createProjectFaviconRequest(
97 lastCacheKey,
98 `https://environment.example/api/assets/revision-${revision}/favicon.svg`,
99 );
100 const endRequest = beginProjectFaviconRequest(request);
101 markProjectFaviconLoaded(request);
102 endRequest();
103 }
104
105 expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false);
106 expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true);
107 });
108});
apps/mobile/src/components/projectFaviconCache.ts deleted-94
...@@ -1,94 +0,0 @@
1export interface ProjectFaviconRequest {
2 readonly cacheKey: string;
3 readonly faviconUrl: string;
4}
5
6interface ActiveFaviconRequests {
7 readonly urls: Map<string, number>;
8 currentUrl: string;
9}
10
11const MAX_LOADED_FAVICONS = 256;
12const activeFaviconRequests = new Map<string, ActiveFaviconRequests>();
13const loadedFaviconKeys = new Map<string, true>();
14
15export function createProjectFaviconRequest(
16 cacheKey: string,
17 faviconUrl: string,
18): ProjectFaviconRequest;
19export function createProjectFaviconRequest(
20 cacheKey: string | null,
21 faviconUrl: string | null,
22): ProjectFaviconRequest | null;
23export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) {
24 if (!cacheKey || !faviconUrl) return null;
25 return { cacheKey, faviconUrl };
26}
27
28export function beginProjectFaviconRequest(request: ProjectFaviconRequest) {
29 let activeRequests = activeFaviconRequests.get(request.cacheKey);
30 if (!activeRequests) {
31 activeRequests = { currentUrl: request.faviconUrl, urls: new Map() };
32 activeFaviconRequests.set(request.cacheKey, activeRequests);
33 }
34
35 const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0;
36 activeRequests.urls.delete(request.faviconUrl);
37 activeRequests.urls.set(request.faviconUrl, activeCount + 1);
38 activeRequests.currentUrl = request.faviconUrl;
39
40 let ended = false;
41 return () => {
42 if (ended) return;
43 ended = true;
44
45 const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1;
46 if (remainingCount > 0) {
47 activeRequests.urls.set(request.faviconUrl, remainingCount);
48 return;
49 }
50
51 activeRequests.urls.delete(request.faviconUrl);
52 if (activeRequests.urls.size === 0) {
53 if (activeFaviconRequests.get(request.cacheKey) === activeRequests) {
54 activeFaviconRequests.delete(request.cacheKey);
55 }
56 return;
57 }
58
59 if (activeRequests.currentUrl === request.faviconUrl) {
60 activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!;
61 }
62 };
63}
64
65export function hasLoadedProjectFavicon(cacheKey: string | null) {
66 return cacheKey !== null && loadedFaviconKeys.has(cacheKey);
67}
68
69function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) {
70 return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl;
71}
72
73function rememberLoadedProjectFavicon(cacheKey: string) {
74 loadedFaviconKeys.delete(cacheKey);
75 loadedFaviconKeys.set(cacheKey, true);
76
77 if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) {
78 loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!);
79 }
80}
81
82export function markProjectFaviconLoaded(request: ProjectFaviconRequest) {
83 if (!isCurrentProjectFaviconRequest(request)) return false;
84
85 rememberLoadedProjectFavicon(request.cacheKey);
86 return true;
87}
88
89export function markProjectFaviconFailed(request: ProjectFaviconRequest) {
90 if (!isCurrentProjectFaviconRequest(request)) return false;
91
92 loadedFaviconKeys.delete(request.cacheKey);
93 return true;
94}
apps/mobile/src/connection/environment-cache-store.ts+6-3
...@@ -15,7 +15,10 @@ import * as Option from "effect/Option";...@@ -15,7 +15,10 @@ import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
1616
17import * as MobileDatabase from "../persistence/mobile-database";17import * as MobileDatabase from "../persistence/mobile-database";
18import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache";18import {
19 attachProjectFaviconDatabase,
20 projectFaviconDatabaseCache,
21} from "../lib/projectFaviconDatabaseCache";
1922
20const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;23const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
21// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump24// v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump
...@@ -130,7 +133,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {...@@ -130,7 +133,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
130 decode: decodeStoredShellSnapshot,133 decode: decodeStoredShellSnapshot,
131 select: (stored) =>134 select: (stored) =>
132 stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),135 stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
133 }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))),136 }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconDatabaseCache.hydrate()))),
134 ),137 ),
135 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {138 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
136 const payload = yield* encodeStoredShellSnapshot({139 const payload = yield* encodeStoredShellSnapshot({
...@@ -241,7 +244,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {...@@ -241,7 +244,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
241 .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),244 .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),
242 ),245 ),
243 clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>246 clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>
244 Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe(247 Effect.promise(() => projectFaviconDatabaseCache.clearEnvironment(environmentId)).pipe(
245 Effect.andThen(database.clearEnvironmentCache(environmentId)),248 Effect.andThen(database.clearEnvironmentCache(environmentId)),
246 Effect.mapError(mapDatabaseError("clear-environment")),249 Effect.mapError(mapDatabaseError("clear-environment")),
247 ),250 ),
apps/mobile/src/lib/projectFaviconCache.test.ts deleted-79
...@@ -1,79 +0,0 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache";
3
4const 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}));
15vi.mock("expo-image", () => ({
16 Image: {
17 loadAsync: native.load,
18 writeToCacheAsync: native.write,
19 getCachePathAsync: native.path,
20 },
21}));
22vi.mock("expo-file-system", () => ({
23 File: class {
24 size = 24_000;
25 base64 = native.read;
26 delete = native.remove;
27 },
28}));
29
30import { downscaleProjectFavicon } from "./projectFaviconCache";
31
32const png = "iVBORw0KGgoAAAAA";
33const image = { url: "https://remote/icon.png" };
34
35beforeEach(() => {
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
45describe("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});
apps/mobile/src/lib/projectFaviconCache.ts deleted-112
...@@ -1,112 +0,0 @@
1import {
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";
8import * as Effect from "effect/Effect";
9
10import * as MobileDatabase from "../persistence/mobile-database";
11
12const CACHE_KIND = "project-favicon";
13const CACHE_SCHEMA_VERSION = 1;
14
15let 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 */
22export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) {
23 database = service;
24}
25
26const 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 */
38export 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. */
82export 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/lib/projectFaviconDatabaseCache.test.ts created+79
...@@ -0,0 +1,79 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache";
3
4const 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}));
15vi.mock("expo-image", () => ({
16 Image: {
17 loadAsync: native.load,
18 writeToCacheAsync: native.write,
19 getCachePathAsync: native.path,
20 },
21}));
22vi.mock("expo-file-system", () => ({
23 File: class {
24 size = 24_000;
25 base64 = native.read;
26 delete = native.remove;
27 },
28}));
29
30import { downscaleProjectFavicon } from "./projectFaviconDatabaseCache";
31
32const png = "iVBORw0KGgoAAAAA";
33const image = { url: "https://remote/icon.png" };
34
35beforeEach(() => {
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
45describe("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});
apps/mobile/src/lib/projectFaviconDatabaseCache.ts created+117
...@@ -0,0 +1,117 @@
1import {
2 createProjectFaviconCache as createSharedProjectFaviconCache,
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";
8import * as Effect from "effect/Effect";
9
10import * as MobileDatabase from "../persistence/mobile-database";
11
12const CACHE_KIND = "project-favicon";
13const CACHE_SCHEMA_VERSION = 1;
14
15let 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 */
22export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) {
23 database = service;
24}
25
26const 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 */
38export 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/**
82 * The database-backed project favicon cache. Named apart from the shared
83 * `createProjectFaviconCache` factory it calls and from the in-flight request
84 * registry in `projectFaviconRequests`. Rows live in `client_cache` so
85 * Settings → Client storage counts and clears them.
86 */
87export const projectFaviconDatabaseCache = createSharedProjectFaviconCache({
88 storage: {
89 list: () =>
90 runDatabase((database) =>
91 database.listCache(CACHE_KIND).pipe(
92 Effect.map((payloads) =>
93 payloads.flatMap((payload): Array<unknown> => {
94 try {
95 return [JSON.parse(payload)];
96 } catch {
97 return [];
98 }
99 }),
100 ),
101 ),
102 ),
103 put: (key, entry: ProjectFaviconEntry) =>
104 runDatabase((database) =>
105 database.saveCache(
106 entry.environmentId,
107 CACHE_KIND,
108 key,
109 CACHE_SCHEMA_VERSION,
110 JSON.stringify(entry),
111 ),
112 ),
113 remove: (key, entry) =>
114 runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)),
115 },
116 load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }),
117});
apps/mobile/src/lib/projectFaviconRequests.test.ts created+108
...@@ -0,0 +1,108 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 beginProjectFaviconRequest,
5 createProjectFaviconRequest,
6 hasLoadedProjectFavicon,
7 markProjectFaviconFailed,
8 markProjectFaviconLoaded,
9} from "./projectFaviconRequests";
10
11describe("project favicon requests", () => {
12 it("ignores callbacks from a superseded URL", () => {
13 const cacheKey = "environment-1:/workspace:v1-favicon.svg";
14 const expiredUrl = "https://environment.example/api/assets/expired/v1-favicon.svg";
15 const refreshedUrl = "https://environment.example/api/assets/refreshed/v1-favicon.svg";
16
17 const expiredRequest = createProjectFaviconRequest(cacheKey, expiredUrl);
18 const endExpiredRequest = beginProjectFaviconRequest(expiredRequest);
19 markProjectFaviconLoaded(expiredRequest);
20 const refreshedRequest = createProjectFaviconRequest(cacheKey, refreshedUrl);
21 const endRefreshedRequest = beginProjectFaviconRequest(refreshedRequest);
22
23 expect(markProjectFaviconLoaded(expiredRequest)).toBe(false);
24 expect(markProjectFaviconFailed(expiredRequest)).toBe(false);
25 expect(hasLoadedProjectFavicon(cacheKey)).toBe(true);
26 expect(markProjectFaviconFailed(refreshedRequest)).toBe(true);
27 expect(hasLoadedProjectFavicon(cacheKey)).toBe(false);
28
29 endRefreshedRequest();
30 endExpiredRequest();
31 });
32
33 it("evicts the URL that actually failed", () => {
34 const cacheKey = "environment-1:/workspace:v2-favicon.svg";
35 const faviconUrl = "https://environment.example/api/assets/current/v2-favicon.svg";
36 const request = createProjectFaviconRequest(cacheKey, faviconUrl);
37 const endRequest = beginProjectFaviconRequest(request);
38
39 markProjectFaviconLoaded(request);
40
41 expect(markProjectFaviconFailed(request)).toBe(true);
42 expect(hasLoadedProjectFavicon(cacheKey)).toBe(false);
43
44 endRequest();
45 });
46
47 it("does not supersede a request until the next request begins", () => {
48 const cacheKey = "environment-1:/workspace:v3-favicon.svg";
49 const committedUrl = "https://environment.example/api/assets/current/v3-favicon.svg";
50 const abandonedUrl = "https://environment.example/api/assets/abandoned/v3-favicon.svg";
51 const committedRequest = createProjectFaviconRequest(cacheKey, committedUrl);
52 const endCommittedRequest = beginProjectFaviconRequest(committedRequest);
53
54 createProjectFaviconRequest(cacheKey, abandonedUrl);
55
56 expect(markProjectFaviconLoaded(committedRequest)).toBe(true);
57 expect(hasLoadedProjectFavicon(cacheKey)).toBe(true);
58
59 endCommittedRequest();
60 });
61
62 it("requires a cache key before creating a URL-bearing request", () => {
63 const firstUrl = "https://environment.example/api/assets/first/favicon.svg";
64 const secondUrl = "https://environment.example/api/assets/second/favicon.svg";
65
66 expect(createProjectFaviconRequest(null, firstUrl)).toBeNull();
67 expect(createProjectFaviconRequest(null, secondUrl)).toBeNull();
68 });
69
70 it("restores the remaining active URL when a newer request ends", () => {
71 const cacheKey = "environment-1:/workspace:v4-favicon.svg";
72 const firstRequest = createProjectFaviconRequest(
73 cacheKey,
74 "https://environment.example/api/assets/first/v4-favicon.svg",
75 );
76 const secondRequest = createProjectFaviconRequest(
77 cacheKey,
78 "https://environment.example/api/assets/second/v4-favicon.svg",
79 );
80 const endFirstRequest = beginProjectFaviconRequest(firstRequest);
81 const endSecondRequest = beginProjectFaviconRequest(secondRequest);
82
83 expect(markProjectFaviconLoaded(firstRequest)).toBe(false);
84 endSecondRequest();
85 expect(markProjectFaviconLoaded(firstRequest)).toBe(true);
86 endFirstRequest();
87 expect(markProjectFaviconLoaded(firstRequest)).toBe(false);
88 });
89
90 it("bounds remembered loaded revisions", () => {
91 const firstCacheKey = "environment-1:/workspace:revision-0";
92 let lastCacheKey = firstCacheKey;
93
94 for (let revision = 0; revision < 300; revision++) {
95 lastCacheKey = `environment-1:/workspace:revision-${revision}`;
96 const request = createProjectFaviconRequest(
97 lastCacheKey,
98 `https://environment.example/api/assets/revision-${revision}/favicon.svg`,
99 );
100 const endRequest = beginProjectFaviconRequest(request);
101 markProjectFaviconLoaded(request);
102 endRequest();
103 }
104
105 expect(hasLoadedProjectFavicon(firstCacheKey)).toBe(false);
106 expect(hasLoadedProjectFavicon(lastCacheKey)).toBe(true);
107 });
108});
apps/mobile/src/lib/projectFaviconRequests.ts created+102
...@@ -0,0 +1,102 @@
1/**
2 * Request bookkeeping behind `ProjectFavicon`: tracks the favicon URLs with
3 * mounted views per cache key, keeps one of them current, and remembers which
4 * keys have already loaded so superseded or already-shown loads settle without
5 * churn. This is not request coalescing — each mount still issues its own
6 * load — and it is not the cache; persistence lives in
7 * `projectFaviconDatabaseCache`.
8 */
9export interface ProjectFaviconRequest {
10 readonly cacheKey: string;
11 readonly faviconUrl: string;
12}
13
14interface ActiveFaviconRequests {
15 readonly urls: Map<string, number>;
16 currentUrl: string;
17}
18
19const MAX_LOADED_FAVICONS = 256;
20const activeFaviconRequests = new Map<string, ActiveFaviconRequests>();
21const loadedFaviconKeys = new Map<string, true>();
22
23export function createProjectFaviconRequest(
24 cacheKey: string,
25 faviconUrl: string,
26): ProjectFaviconRequest;
27export function createProjectFaviconRequest(
28 cacheKey: string | null,
29 faviconUrl: string | null,
30): ProjectFaviconRequest | null;
31export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) {
32 if (!cacheKey || !faviconUrl) return null;
33 return { cacheKey, faviconUrl };
34}
35
36export function beginProjectFaviconRequest(request: ProjectFaviconRequest) {
37 let activeRequests = activeFaviconRequests.get(request.cacheKey);
38 if (!activeRequests) {
39 activeRequests = { currentUrl: request.faviconUrl, urls: new Map() };
40 activeFaviconRequests.set(request.cacheKey, activeRequests);
41 }
42
43 const activeCount = activeRequests.urls.get(request.faviconUrl) ?? 0;
44 activeRequests.urls.delete(request.faviconUrl);
45 activeRequests.urls.set(request.faviconUrl, activeCount + 1);
46 activeRequests.currentUrl = request.faviconUrl;
47
48 let ended = false;
49 return () => {
50 if (ended) return;
51 ended = true;
52
53 const remainingCount = (activeRequests.urls.get(request.faviconUrl) ?? 1) - 1;
54 if (remainingCount > 0) {
55 activeRequests.urls.set(request.faviconUrl, remainingCount);
56 return;
57 }
58
59 activeRequests.urls.delete(request.faviconUrl);
60 if (activeRequests.urls.size === 0) {
61 if (activeFaviconRequests.get(request.cacheKey) === activeRequests) {
62 activeFaviconRequests.delete(request.cacheKey);
63 }
64 return;
65 }
66
67 if (activeRequests.currentUrl === request.faviconUrl) {
68 activeRequests.currentUrl = Array.from(activeRequests.urls.keys()).at(-1)!;
69 }
70 };
71}
72
73export function hasLoadedProjectFavicon(cacheKey: string | null) {
74 return cacheKey !== null && loadedFaviconKeys.has(cacheKey);
75}
76
77function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) {
78 return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl;
79}
80
81function rememberLoadedProjectFavicon(cacheKey: string) {
82 loadedFaviconKeys.delete(cacheKey);
83 loadedFaviconKeys.set(cacheKey, true);
84
85 if (loadedFaviconKeys.size > MAX_LOADED_FAVICONS) {
86 loadedFaviconKeys.delete(loadedFaviconKeys.keys().next().value!);
87 }
88}
89
90export function markProjectFaviconLoaded(request: ProjectFaviconRequest) {
91 if (!isCurrentProjectFaviconRequest(request)) return false;
92
93 rememberLoadedProjectFavicon(request.cacheKey);
94 return true;
95}
96
97export function markProjectFaviconFailed(request: ProjectFaviconRequest) {
98 if (!isCurrentProjectFaviconRequest(request)) return false;
99
100 loadedFaviconKeys.delete(request.cacheKey);
101 return true;
102}
apps/mobile/src/state/assets.ts+2-2
...@@ -16,7 +16,7 @@ import { useCallback } from "react";...@@ -16,7 +16,7 @@ import { useCallback } from "react";
1616
17import { environmentCatalog } from "../connection/catalog";17import { environmentCatalog } from "../connection/catalog";
18import { connectionAtomRuntime } from "../connection/runtime";18import { connectionAtomRuntime } from "../connection/runtime";
19import { projectFaviconCache } from "../lib/projectFaviconCache";19import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
20import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";20import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state";
21import { environmentSession, usePreparedConnection } from "./session";21import { environmentSession, usePreparedConnection } from "./session";
22import { useAtomQueryRunner } from "./use-atom-query-runner";22import { useAtomQueryRunner } from "./use-atom-query-runner";
...@@ -26,7 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";...@@ -26,7 +26,7 @@ export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state";
26export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);26export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime);
2727
28export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({28export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({
29 imageCache: projectFaviconCache,29 imageCache: projectFaviconDatabaseCache,
30 createUrl: assetEnvironment.createUrl,30 createUrl: assetEnvironment.createUrl,
31 preparedConnection: environmentSession.preparedConnectionValueAtom,31 preparedConnection: environmentSession.preparedConnectionValueAtom,
32});32});
apps/mobile/src/state/client-cache-state.ts+3-3
...@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";...@@ -3,7 +3,7 @@ import * as Effect from "effect/Effect";
3import { Atom } from "effect/unstable/reactivity";3import { Atom } from "effect/unstable/reactivity";
44
5import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";5import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";
6import { projectFaviconCache } from "../lib/projectFaviconCache";6import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache";
7import * as Runtime from "../lib/runtime";7import * as Runtime from "../lib/runtime";
88
9export interface EnvironmentClientCacheSummary {9export interface EnvironmentClientCacheSummary {
...@@ -74,8 +74,8 @@ export const clearClientCacheAtom = clientCacheRuntime...@@ -74,8 +74,8 @@ export const clearClientCacheAtom = clientCacheRuntime
74 .fn((scope: ClientCacheClearScope, get) =>74 .fn((scope: ClientCacheClearScope, get) =>
75 Effect.promise(() =>75 Effect.promise(() =>
76 scope.type === "all"76 scope.type === "all"
77 ? projectFaviconCache.clearAll()77 ? projectFaviconDatabaseCache.clearAll()
78 : projectFaviconCache.clearEnvironment(scope.environmentId),78 : projectFaviconDatabaseCache.clearEnvironment(scope.environmentId),
79 ).pipe(79 ).pipe(
80 Effect.andThen(MobileDatabase),80 Effect.andThen(MobileDatabase),
81 Effect.flatMap((database) =>81 Effect.flatMap((database) =>