| author | |
| committer | |
| log | 34096519121033ccf72cd43ea5350f9482df188a |
| tree | 8333ca417729caf474d3821162b71c62a5acdfe8 |
| parent | ec28eefa0d017e2b0cb65ba4269cee1adddb0e98 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 22 | 22 | ||
| 23 | const EMPTY_FAVICON_URL = Atom.make<string | null>(null); | 23 | const EMPTY_FAVICON_URL = Atom.make<string | null>(null); |
| 24 | 24 |
apps/mobile/src/components/projectFaviconCache.test.ts deleted-108| ... | @@ -1,108 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | beginProjectFaviconRequest, | ||
| 5 | createProjectFaviconRequest, | ||
| 6 | hasLoadedProjectFavicon, | ||
| 7 | markProjectFaviconFailed, | ||
| 8 | markProjectFaviconLoaded, | ||
| 9 | } from "./projectFaviconCache"; | ||
| 10 | |||
| 11 | describe("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 @@ | ||
| 1 | export interface ProjectFaviconRequest { | ||
| 2 | readonly cacheKey: string; | ||
| 3 | readonly faviconUrl: string; | ||
| 4 | } | ||
| 5 | |||
| 6 | interface ActiveFaviconRequests { | ||
| 7 | readonly urls: Map<string, number>; | ||
| 8 | currentUrl: string; | ||
| 9 | } | ||
| 10 | |||
| 11 | const MAX_LOADED_FAVICONS = 256; | ||
| 12 | const activeFaviconRequests = new Map<string, ActiveFaviconRequests>(); | ||
| 13 | const loadedFaviconKeys = new Map<string, true>(); | ||
| 14 | |||
| 15 | export function createProjectFaviconRequest( | ||
| 16 | cacheKey: string, | ||
| 17 | faviconUrl: string, | ||
| 18 | ): ProjectFaviconRequest; | ||
| 19 | export function createProjectFaviconRequest( | ||
| 20 | cacheKey: string | null, | ||
| 21 | faviconUrl: string | null, | ||
| 22 | ): ProjectFaviconRequest | null; | ||
| 23 | export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { | ||
| 24 | if (!cacheKey || !faviconUrl) return null; | ||
| 25 | return { cacheKey, faviconUrl }; | ||
| 26 | } | ||
| 27 | |||
| 28 | export 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 | |||
| 65 | export function hasLoadedProjectFavicon(cacheKey: string | null) { | ||
| 66 | return cacheKey !== null && loadedFaviconKeys.has(cacheKey); | ||
| 67 | } | ||
| 68 | |||
| 69 | function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { | ||
| 70 | return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; | ||
| 71 | } | ||
| 72 | |||
| 73 | function 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 | |||
| 82 | export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { | ||
| 83 | if (!isCurrentProjectFaviconRequest(request)) return false; | ||
| 84 | |||
| 85 | rememberLoadedProjectFavicon(request.cacheKey); | ||
| 86 | return true; | ||
| 87 | } | ||
| 88 | |||
| 89 | export 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"; |
| 15 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 16 | 16 | ||
| 17 | import * as MobileDatabase from "../persistence/mobile-database"; | 17 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 18 | import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; | 18 | import { |
| 19 | attachProjectFaviconDatabase, | ||
| 20 | projectFaviconDatabaseCache, | ||
| 21 | } from "../lib/projectFaviconDatabaseCache"; | ||
| 19 | 22 | ||
| 20 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | 23 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; |
| 21 | // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump | 24 | // 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 @@ | ||
| 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 | }); | ||
apps/mobile/src/lib/projectFaviconCache.ts deleted-112| ... | @@ -1,112 +0,0 @@ | ||
| 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/lib/projectFaviconDatabaseCache.test.ts created+79| ... | @@ -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 "./projectFaviconDatabaseCache"; | ||
| 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 | }); | ||
apps/mobile/src/lib/projectFaviconDatabaseCache.ts created+117| ... | @@ -0,0 +1,117 @@ | ||
| 1 | import { | ||
| 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"; | ||
| 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 | /** | ||
| 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 | */ | ||
| 87 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | beginProjectFaviconRequest, | ||
| 5 | createProjectFaviconRequest, | ||
| 6 | hasLoadedProjectFavicon, | ||
| 7 | markProjectFaviconFailed, | ||
| 8 | markProjectFaviconLoaded, | ||
| 9 | } from "./projectFaviconRequests"; | ||
| 10 | |||
| 11 | describe("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 | */ | ||
| 9 | export interface ProjectFaviconRequest { | ||
| 10 | readonly cacheKey: string; | ||
| 11 | readonly faviconUrl: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | interface ActiveFaviconRequests { | ||
| 15 | readonly urls: Map<string, number>; | ||
| 16 | currentUrl: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | const MAX_LOADED_FAVICONS = 256; | ||
| 20 | const activeFaviconRequests = new Map<string, ActiveFaviconRequests>(); | ||
| 21 | const loadedFaviconKeys = new Map<string, true>(); | ||
| 22 | |||
| 23 | export function createProjectFaviconRequest( | ||
| 24 | cacheKey: string, | ||
| 25 | faviconUrl: string, | ||
| 26 | ): ProjectFaviconRequest; | ||
| 27 | export function createProjectFaviconRequest( | ||
| 28 | cacheKey: string | null, | ||
| 29 | faviconUrl: string | null, | ||
| 30 | ): ProjectFaviconRequest | null; | ||
| 31 | export function createProjectFaviconRequest(cacheKey: string | null, faviconUrl: string | null) { | ||
| 32 | if (!cacheKey || !faviconUrl) return null; | ||
| 33 | return { cacheKey, faviconUrl }; | ||
| 34 | } | ||
| 35 | |||
| 36 | export 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 | |||
| 73 | export function hasLoadedProjectFavicon(cacheKey: string | null) { | ||
| 74 | return cacheKey !== null && loadedFaviconKeys.has(cacheKey); | ||
| 75 | } | ||
| 76 | |||
| 77 | function isCurrentProjectFaviconRequest(request: ProjectFaviconRequest) { | ||
| 78 | return activeFaviconRequests.get(request.cacheKey)?.currentUrl === request.faviconUrl; | ||
| 79 | } | ||
| 80 | |||
| 81 | function 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 | |||
| 90 | export function markProjectFaviconLoaded(request: ProjectFaviconRequest) { | ||
| 91 | if (!isCurrentProjectFaviconRequest(request)) return false; | ||
| 92 | |||
| 93 | rememberLoadedProjectFavicon(request.cacheKey); | ||
| 94 | return true; | ||
| 95 | } | ||
| 96 | |||
| 97 | export 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"; |
| 16 | 16 | ||
| 17 | import { environmentCatalog } from "../connection/catalog"; | 17 | import { environmentCatalog } from "../connection/catalog"; |
| 18 | import { connectionAtomRuntime } from "../connection/runtime"; | 18 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 19 | import { projectFaviconCache } from "../lib/projectFaviconCache"; | 19 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; |
| 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; | 20 | import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; |
| 21 | import { environmentSession, usePreparedConnection } from "./session"; | 21 | import { environmentSession, usePreparedConnection } from "./session"; |
| 22 | import { useAtomQueryRunner } from "./use-atom-query-runner"; | 22 | import { 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"; |
| 26 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); | 26 | export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); |
| 27 | 27 | ||
| 28 | export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ | 28 | export 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"; |
| 3 | import { Atom } from "effect/unstable/reactivity"; | 3 | import { Atom } from "effect/unstable/reactivity"; |
| 4 | 4 | ||
| 5 | import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; | 5 | import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; |
| 6 | import { projectFaviconCache } from "../lib/projectFaviconCache"; | 6 | import { projectFaviconDatabaseCache } from "../lib/projectFaviconDatabaseCache"; |
| 7 | import * as Runtime from "../lib/runtime"; | 7 | import * as Runtime from "../lib/runtime"; |
| 8 | 8 | ||
| 9 | export interface EnvironmentClientCacheSummary { | 9 | export 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) => |