fix(image-pipeline): bound source bitmap cache lifecycle
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
const imageBitmapCache = new Map<string, Promise<ImageBitmap>>();
|
||||
export const SOURCE_BITMAP_CACHE_MAX_ENTRIES = 32;
|
||||
|
||||
type CacheEntry = {
|
||||
promise: Promise<ImageBitmap>;
|
||||
bitmap?: ImageBitmap;
|
||||
};
|
||||
|
||||
const imageBitmapCache = new Map<string, CacheEntry>();
|
||||
|
||||
type LoadSourceBitmapOptions = {
|
||||
signal?: AbortSignal;
|
||||
@@ -10,12 +17,58 @@ function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
function closeBitmap(bitmap: ImageBitmap | undefined): void {
|
||||
if (typeof bitmap?.close === "function") {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCacheEntry(sourceUrl: string): void {
|
||||
const entry = imageBitmapCache.get(sourceUrl);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
imageBitmapCache.delete(sourceUrl);
|
||||
closeBitmap(entry.bitmap);
|
||||
}
|
||||
|
||||
function touchCacheEntry(sourceUrl: string, entry: CacheEntry): void {
|
||||
imageBitmapCache.delete(sourceUrl);
|
||||
imageBitmapCache.set(sourceUrl, entry);
|
||||
}
|
||||
|
||||
function evictIfNeeded(excludeSourceUrl?: string): void {
|
||||
while (imageBitmapCache.size > SOURCE_BITMAP_CACHE_MAX_ENTRIES) {
|
||||
const oldestSourceUrl = [...imageBitmapCache.entries()].find(
|
||||
([key, entry]) => key !== excludeSourceUrl && entry.bitmap,
|
||||
)?.[0];
|
||||
|
||||
if (!oldestSourceUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteCacheEntry(oldestSourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSourceBitmapCache(): void {
|
||||
for (const sourceUrl of [...imageBitmapCache.keys()]) {
|
||||
deleteCacheEntry(sourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise<ImageBitmap> {
|
||||
const cached = imageBitmapCache.get(sourceUrl);
|
||||
if (cached) {
|
||||
return cached;
|
||||
touchCacheEntry(sourceUrl, cached);
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
const entry: CacheEntry = {
|
||||
promise: Promise.resolve(undefined as never),
|
||||
};
|
||||
|
||||
const promise = (async () => {
|
||||
const response = await fetch(sourceUrl);
|
||||
if (!response.ok) {
|
||||
@@ -23,13 +76,17 @@ function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise<ImageBitmap>
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return await createImageBitmap(blob);
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
entry.bitmap = bitmap;
|
||||
evictIfNeeded(sourceUrl);
|
||||
return bitmap;
|
||||
})();
|
||||
|
||||
imageBitmapCache.set(sourceUrl, promise);
|
||||
entry.promise = promise;
|
||||
imageBitmapCache.set(sourceUrl, entry);
|
||||
|
||||
void promise.catch(() => {
|
||||
if (imageBitmapCache.get(sourceUrl) === promise) {
|
||||
if (imageBitmapCache.get(sourceUrl) === entry) {
|
||||
imageBitmapCache.delete(sourceUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -167,4 +167,90 @@ describe("loadSourceBitmap", () => {
|
||||
expect(succeedingResponse.blob).toHaveBeenCalledTimes(1);
|
||||
expect(createImageBitmap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("evicts the least-recently-used bitmap once the cache limit is exceeded", async () => {
|
||||
const subject = (await importSubject()) as typeof import("@/lib/image-pipeline/source-loader") & {
|
||||
SOURCE_BITMAP_CACHE_MAX_ENTRIES?: number;
|
||||
};
|
||||
const { SOURCE_BITMAP_CACHE_MAX_ENTRIES, loadSourceBitmap } = subject;
|
||||
|
||||
expect(SOURCE_BITMAP_CACHE_MAX_ENTRIES).toBeTypeOf("number");
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async (input: string | URL | Request) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: vi.fn().mockResolvedValue(new Blob([String(input)])),
|
||||
})),
|
||||
);
|
||||
|
||||
const urls = Array.from(
|
||||
{ length: SOURCE_BITMAP_CACHE_MAX_ENTRIES! + 1 },
|
||||
(_, index) => `https://cdn.example.com/source-${index}.png`,
|
||||
);
|
||||
|
||||
for (const url of urls) {
|
||||
await expect(loadSourceBitmap(url)).resolves.toBe(bitmap);
|
||||
}
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(urls.length);
|
||||
|
||||
await expect(loadSourceBitmap(urls[urls.length - 1])).resolves.toBe(bitmap);
|
||||
expect(fetch).toHaveBeenCalledTimes(urls.length);
|
||||
|
||||
await expect(loadSourceBitmap(urls[0])).resolves.toBe(bitmap);
|
||||
expect(fetch).toHaveBeenCalledTimes(urls.length + 1);
|
||||
});
|
||||
|
||||
it("closes evicted bitmaps and disposes the remaining cache explicitly", async () => {
|
||||
const subject = (await importSubject()) as typeof import("@/lib/image-pipeline/source-loader") & {
|
||||
SOURCE_BITMAP_CACHE_MAX_ENTRIES?: number;
|
||||
clearSourceBitmapCache?: () => void;
|
||||
};
|
||||
const { SOURCE_BITMAP_CACHE_MAX_ENTRIES, clearSourceBitmapCache, loadSourceBitmap } = subject;
|
||||
|
||||
expect(SOURCE_BITMAP_CACHE_MAX_ENTRIES).toBeGreaterThanOrEqual(2);
|
||||
expect(clearSourceBitmapCache).toBeTypeOf("function");
|
||||
|
||||
const bitmaps = Array.from(
|
||||
{ length: SOURCE_BITMAP_CACHE_MAX_ENTRIES! + 1 },
|
||||
() => ({ close: vi.fn() }) as unknown as ImageBitmap,
|
||||
);
|
||||
let bitmapIndex = 0;
|
||||
|
||||
vi.stubGlobal(
|
||||
"createImageBitmap",
|
||||
vi.fn().mockImplementation(async () => bitmaps[bitmapIndex++]),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation(async (input: string | URL | Request) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
blob: vi.fn().mockResolvedValue(new Blob([String(input)])),
|
||||
})),
|
||||
);
|
||||
|
||||
const urls = Array.from(
|
||||
{ length: SOURCE_BITMAP_CACHE_MAX_ENTRIES! + 1 },
|
||||
(_, index) => `https://cdn.example.com/source-${index}.png`,
|
||||
);
|
||||
|
||||
for (const [index, url] of urls.entries()) {
|
||||
await expect(loadSourceBitmap(url)).resolves.toBe(bitmaps[index]);
|
||||
}
|
||||
|
||||
expect(bitmaps[0].close).toHaveBeenCalledTimes(1);
|
||||
for (const bitmap of bitmaps.slice(1)) {
|
||||
expect(bitmap.close).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
clearSourceBitmapCache!();
|
||||
|
||||
expect(bitmaps[0].close).toHaveBeenCalledTimes(1);
|
||||
for (const bitmap of bitmaps.slice(1)) {
|
||||
expect(bitmap.close).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user