From a6c8b50ceac419a130e5cb83e9e89bd8dfc89528 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Fri, 3 Apr 2026 09:35:16 +0300 Subject: [PATCH] fix(appliedFontsStore): solve ttl based fonts purge by adding cache for on-screen fonts --- .../appliedFontStore.test.ts | 71 +++++++++++++++++++ .../appliedFontsStore.svelte.ts | 41 ++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index e1740f7..e0eec49 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -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); + }); }); diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 2032a52..17e58cf 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -75,6 +75,15 @@ export class AppliedFontsManager { // Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped #retryCounts = new Map(); + // In-memory buffer cache keyed by URL — fastest tier, checked before Cache API and network + #buffersByUrl = new Map(); + + // Maps font key → URL so #purgeUnused() can evict from #buffersByUrl + #urlByKey = new Map(); + + // Fonts currently visible/in-use; purge skips these regardless of TTL + #pinnedFonts = new Set(); + 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 { + // 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 { 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(); } -- 2.49.1