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 = {
|
type LoadSourceBitmapOptions = {
|
||||||
signal?: AbortSignal;
|
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> {
|
function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise<ImageBitmap> {
|
||||||
const cached = imageBitmapCache.get(sourceUrl);
|
const cached = imageBitmapCache.get(sourceUrl);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
touchCacheEntry(sourceUrl, cached);
|
||||||
|
return cached.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entry: CacheEntry = {
|
||||||
|
promise: Promise.resolve(undefined as never),
|
||||||
|
};
|
||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
const response = await fetch(sourceUrl);
|
const response = await fetch(sourceUrl);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -23,13 +76,17 @@ function getOrCreateSourceBitmapPromise(sourceUrl: string): Promise<ImageBitmap>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
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(() => {
|
void promise.catch(() => {
|
||||||
if (imageBitmapCache.get(sourceUrl) === promise) {
|
if (imageBitmapCache.get(sourceUrl) === entry) {
|
||||||
imageBitmapCache.delete(sourceUrl);
|
imageBitmapCache.delete(sourceUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -167,4 +167,90 @@ describe("loadSourceBitmap", () => {
|
|||||||
expect(succeedingResponse.blob).toHaveBeenCalledTimes(1);
|
expect(succeedingResponse.blob).toHaveBeenCalledTimes(1);
|
||||||
expect(createImageBitmap).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