fix(appliedFontsStore): solve ttl based fonts purge by adding cache for on-screen fonts #30

Merged
ilia merged 1 commits from fix/ttl-based-purge into main 2026-04-03 06:38:01 +00:00
2 changed files with 110 additions and 2 deletions

View File

@@ -139,4 +139,75 @@ describe('AppliedFontsManager', () => {
expect(manager.getFontStatus('active', 400)).toBe('loaded'); 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);
});
}); });

View File

@@ -75,6 +75,15 @@ export class AppliedFontsManager {
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped // Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
#retryCounts = new Map<string, number>(); #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 #MAX_RETRIES = 3;
readonly #PURGE_INTERVAL = 60000; // 60 seconds readonly #PURGE_INTERVAL = 60000; // 60 seconds
readonly #TTL = 5 * 60 * 1000; // 5 minutes readonly #TTL = 5 * 60 * 1000; // 5 minutes
@@ -236,6 +245,8 @@ export class AppliedFontsManager {
await font.load(); await font.load();
document.fonts.add(font); document.fonts.add(font);
this.#loadedFonts.set(key, font); this.#loadedFonts.set(key, font);
this.#buffersByUrl.set(config.url, buffer);
this.#urlByKey.set(key, config.url);
this.statuses.set(key, 'loaded'); this.statuses.set(key, 'loaded');
} catch (e) { } catch (e) {
if (e instanceof Error && e.name === 'AbortError') continue; 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. * Cache failures (private browsing, quota limits) are silently ignored.
*/ */
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> { 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 { try {
if (typeof caches !== 'undefined') { if (typeof caches !== 'undefined') {
const cache = await caches.open(this.#CACHE_NAME); 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 // Cache unavailable (private browsing, security restrictions) — fall through to network
} }
// Tier 3: network
const response = await fetch(url, { signal }); const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -285,15 +302,22 @@ export class AppliedFontsManager {
return response.arrayBuffer(); 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() { #purgeUnused() {
const now = Date.now(); const now = Date.now();
for (const [key, lastUsed] of this.#usageTracker) { for (const [key, lastUsed] of this.#usageTracker) {
if (now - lastUsed < this.#TTL) continue; if (now - lastUsed < this.#TTL) continue;
if (this.#pinnedFonts.has(key)) continue;
const font = this.#loadedFonts.get(key); const font = this.#loadedFonts.get(key);
if (font) document.fonts.delete(font); 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.#loadedFonts.delete(key);
this.#usageTracker.delete(key); this.#usageTracker.delete(key);
this.statuses.delete(key); this.statuses.delete(key);
@@ -306,6 +330,16 @@ export class AppliedFontsManager {
return this.statuses.get(this.#getFontKey(id, weight, isVariable)); 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. */ /** Waits for all fonts to finish loading using document.fonts.ready. */
async ready(): Promise<void> { async ready(): Promise<void> {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
@@ -345,6 +379,9 @@ export class AppliedFontsManager {
this.#loadedFonts.clear(); this.#loadedFonts.clear();
this.#usageTracker.clear(); this.#usageTracker.clear();
this.#retryCounts.clear(); this.#retryCounts.clear();
this.#buffersByUrl.clear();
this.#urlByKey.clear();
this.#pinnedFonts.clear();
this.statuses.clear(); this.statuses.clear();
this.#queue.clear(); this.#queue.clear();
} }