fix(appliedFontsStore): solve ttl based fonts purge by adding cache for on-screen fonts
All checks were successful
Workflow / build (pull_request) Successful in 3m45s
Workflow / publish (pull_request) Has been skipped

This commit is contained in:
Ilia Mashkov
2026-04-03 09:35:16 +03:00
parent 11c4750d0e
commit a6c8b50cea
2 changed files with 110 additions and 2 deletions

View File

@@ -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);
});
});

View File

@@ -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();
}