Merge pull request 'fix(appliedFontsStore): solve ttl based fonts purge by adding cache for on-screen fonts' (#30) from fix/ttl-based-purge into main
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
@@ -139,4 +139,75 @@ describe('AppliedFontsManager', () => {
|
||||
|
||||
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||
});
|
||||
|
||||
it('should serve buffer from memory without calling fetch again', async () => {
|
||||
const config = { id: 'cached', name: 'Cached', url: 'https://example.com/cached.ttf', weight: 400 };
|
||||
|
||||
// First load — populates in-memory buffer
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate eviction by deleting the status entry directly
|
||||
manager.statuses.delete('cached@400');
|
||||
|
||||
// Second load — should hit in-memory buffer, not network
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
||||
// fetch should still only have been called once (buffer was reused)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should NOT purge a pinned font after TTL expires', async () => {
|
||||
const config = { id: 'pinned', name: 'Pinned', url: 'https://example.com/pinned.ttf', weight: 400 };
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
||||
|
||||
manager.pin('pinned', 400);
|
||||
|
||||
// Advance past TTL + purge interval
|
||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
||||
|
||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
||||
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should evict a font after it is unpinned and TTL expires', async () => {
|
||||
const config = { id: 'unpinned', name: 'Unpinned', url: 'https://example.com/unpinned.ttf', weight: 400 };
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(manager.getFontStatus('unpinned', 400)).toBe('loaded');
|
||||
|
||||
manager.pin('unpinned', 400);
|
||||
manager.unpin('unpinned', 400);
|
||||
|
||||
// Advance past TTL + purge interval
|
||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
||||
|
||||
expect(manager.getFontStatus('unpinned', 400)).toBeUndefined();
|
||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear pinned set on destroy without errors', async () => {
|
||||
const config = {
|
||||
id: 'destroy-pin',
|
||||
name: 'DestroyPin',
|
||||
url: 'https://example.com/destroypin.ttf',
|
||||
weight: 400,
|
||||
};
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
manager.pin('destroy-pin', 400);
|
||||
manager.destroy();
|
||||
|
||||
expect(manager.statuses.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,15 @@ export class AppliedFontsManager {
|
||||
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
|
||||
#retryCounts = new Map<string, number>();
|
||||
|
||||
// In-memory buffer cache keyed by URL — fastest tier, checked before Cache API and network
|
||||
#buffersByUrl = new Map<string, ArrayBuffer>();
|
||||
|
||||
// Maps font key → URL so #purgeUnused() can evict from #buffersByUrl
|
||||
#urlByKey = new Map<string, string>();
|
||||
|
||||
// Fonts currently visible/in-use; purge skips these regardless of TTL
|
||||
#pinnedFonts = new Set<string>();
|
||||
|
||||
readonly #MAX_RETRIES = 3;
|
||||
readonly #PURGE_INTERVAL = 60000; // 60 seconds
|
||||
readonly #TTL = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -236,6 +245,8 @@ export class AppliedFontsManager {
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
this.#loadedFonts.set(key, font);
|
||||
this.#buffersByUrl.set(config.url, buffer);
|
||||
this.#urlByKey.set(key, config.url);
|
||||
this.statuses.set(key, 'loaded');
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') continue;
|
||||
@@ -256,10 +267,15 @@ export class AppliedFontsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
|
||||
* Fetches font with three-tier lookup: in-memory buffer → Cache API → network.
|
||||
* Cache failures (private browsing, quota limits) are silently ignored.
|
||||
*/
|
||||
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||
// Tier 1: in-memory buffer (fastest, no I/O)
|
||||
const inMemory = this.#buffersByUrl.get(url);
|
||||
if (inMemory) return inMemory;
|
||||
|
||||
// Tier 2: Cache API
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#CACHE_NAME);
|
||||
@@ -270,6 +286,7 @@ export class AppliedFontsManager {
|
||||
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
||||
}
|
||||
|
||||
// Tier 3: network
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
@@ -285,15 +302,22 @@ export class AppliedFontsManager {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
|
||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
for (const [key, lastUsed] of this.#usageTracker) {
|
||||
if (now - lastUsed < this.#TTL) continue;
|
||||
if (this.#pinnedFonts.has(key)) continue;
|
||||
|
||||
const font = this.#loadedFonts.get(key);
|
||||
if (font) document.fonts.delete(font);
|
||||
|
||||
const url = this.#urlByKey.get(key);
|
||||
if (url) {
|
||||
this.#buffersByUrl.delete(url);
|
||||
this.#urlByKey.delete(key);
|
||||
}
|
||||
|
||||
this.#loadedFonts.delete(key);
|
||||
this.#usageTracker.delete(key);
|
||||
this.statuses.delete(key);
|
||||
@@ -306,6 +330,16 @@ export class AppliedFontsManager {
|
||||
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
||||
}
|
||||
|
||||
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
|
||||
pin(id: string, weight: number, isVariable?: boolean): void {
|
||||
this.#pinnedFonts.add(this.#getFontKey(id, weight, !!isVariable));
|
||||
}
|
||||
|
||||
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
|
||||
unpin(id: string, weight: number, isVariable?: boolean): void {
|
||||
this.#pinnedFonts.delete(this.#getFontKey(id, weight, !!isVariable));
|
||||
}
|
||||
|
||||
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
||||
async ready(): Promise<void> {
|
||||
if (typeof document === 'undefined') return;
|
||||
@@ -345,6 +379,9 @@ export class AppliedFontsManager {
|
||||
this.#loadedFonts.clear();
|
||||
this.#usageTracker.clear();
|
||||
this.#retryCounts.clear();
|
||||
this.#buffersByUrl.clear();
|
||||
this.#urlByKey.clear();
|
||||
this.#pinnedFonts.clear();
|
||||
this.statuses.clear();
|
||||
this.#queue.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user