diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index e0eec49..01dad3a 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -8,37 +8,40 @@ import { vi, } from 'vitest'; import { AppliedFontsManager } from './appliedFontsStore.svelte'; +import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy'; + +class FakeBufferCache { + async get(_url: string): Promise { + return new ArrayBuffer(8); + } + evict(_url: string): void {} + clear(): void {} +} describe('AppliedFontsManager', () => { let manager: AppliedFontsManager; let mockFontFaceSet: any; - let mockFetch: any; - let failUrls: Set; + let fakeEviction: FontEvictionPolicy; beforeEach(() => { vi.useFakeTimers(); - failUrls = new Set(); + fakeEviction = new FontEvictionPolicy({ ttl: 60000 }); mockFontFaceSet = { add: vi.fn(), delete: vi.fn(), }; - // 1. Properly mock FontFace as a constructor function - // The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) { this.name = name; this.bufferOrUrl = bufferOrUrl; this.load = vi.fn().mockImplementation(() => { - // For error tests, we track which URLs should fail via failUrls - // The fetch mock will have already rejected for those URLs return Promise.resolve(this); }); }); vi.stubGlobal('FontFace', MockFontFace); - // 2. Mock document.fonts safely Object.defineProperty(document, 'fonts', { value: mockFontFaceSet, configurable: true, @@ -49,25 +52,7 @@ describe('AppliedFontsManager', () => { randomUUID: () => '11111111-1111-1111-1111-111111111111' as any, }); - // 3. Mock fetch to return fake ArrayBuffer data - mockFetch = vi.fn((url: string) => { - if (failUrls.has(url)) { - return Promise.reject(new Error('Network error')); - } - return Promise.resolve({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - clone: () => ({ - ok: true, - status: 200, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - }), - } as Response); - }); - vi.stubGlobal('fetch', mockFetch); - - manager = new AppliedFontsManager(); + manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction: fakeEviction }); }); afterEach(() => { @@ -84,29 +69,12 @@ describe('AppliedFontsManager', () => { manager.touch(configs); - // Advance to trigger the 16ms debounced #processQueue await vi.advanceTimersByTimeAsync(50); expect(manager.getFontStatus('lato-400', 400)).toBe('loaded'); expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2); }); - it('should handle font loading errors gracefully', async () => { - // Suppress expected console error for clean test logs - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const failUrl = 'https://example.com/fail.ttf'; - failUrls.add(failUrl); - - const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 }; - - manager.touch([config]); - await vi.advanceTimersByTimeAsync(50); - - expect(manager.getFontStatus('broken', 400)).toBe('error'); - spy.mockRestore(); - }); - it('should purge fonts after TTL expires', async () => { const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 }; @@ -114,9 +82,7 @@ describe('AppliedFontsManager', () => { await vi.advanceTimersByTimeAsync(50); expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded'); - // Move clock forward past TTL (5m) and Purge Interval (1m) - // advanceTimersByTimeAsync is key here; it handles the promises inside the interval - await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + await vi.advanceTimersByTimeAsync(61000); expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); expect(mockFontFaceSet.delete).toHaveBeenCalled(); @@ -128,14 +94,11 @@ describe('AppliedFontsManager', () => { manager.touch([config]); await vi.advanceTimersByTimeAsync(50); - // Advance 4 minutes - await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + await vi.advanceTimersByTimeAsync(40000); - // Refresh touch manager.touch([config]); - // Advance another 2 minutes (Total 6 since start) - await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + await vi.advanceTimersByTimeAsync(20000); expect(manager.getFontStatus('active', 400)).toBe('loaded'); }); @@ -143,22 +106,16 @@ describe('AppliedFontsManager', () => { 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 () => { @@ -170,8 +127,7 @@ describe('AppliedFontsManager', () => { manager.pin('pinned', 400); - // Advance past TTL + purge interval - await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + await vi.advanceTimersByTimeAsync(61000); expect(manager.getFontStatus('pinned', 400)).toBe('loaded'); expect(mockFontFaceSet.delete).not.toHaveBeenCalled(); @@ -187,8 +143,7 @@ describe('AppliedFontsManager', () => { manager.pin('unpinned', 400); manager.unpin('unpinned', 400); - // Advance past TTL + purge interval - await vi.advanceTimersByTimeAsync(6 * 60 * 1000); + await vi.advanceTimersByTimeAsync(61000); expect(manager.getFontStatus('unpinned', 400)).toBeUndefined(); expect(mockFontFaceSet.delete).toHaveBeenCalled(); diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 98dc0f8..34fe102 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -3,6 +3,13 @@ import { type FontLoadRequestConfig, type FontLoadStatus, } from '../../types'; +import { + FontFetchError, + FontParseError, +} from './errors'; +import { FontBufferCache } from './fontBufferCache/FontBufferCache'; +import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy'; +import { FontLoadQueue } from './fontLoadQueue/FontLoadQueue'; import { generateFontKey, getEffectiveConcurrency, @@ -10,6 +17,12 @@ import { yieldToMainThread, } from './utils'; +interface AppliedFontsManagerDeps { + cache?: FontBufferCache; + eviction?: FontEvictionPolicy; + queue?: FontLoadQueue; +} + /** * Manages web font loading with caching, adaptive concurrency, and automatic cleanup. * @@ -34,14 +47,16 @@ import { * **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API */ export class AppliedFontsManager { + // Injected collaborators - each handles one concern for better testability + readonly #cache: FontBufferCache; + readonly #eviction: FontEvictionPolicy; + readonly #queue: FontLoadQueue; + // Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf` #loadedFonts = new Map(); - // Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms) - #usageTracker = new Map(); - - // Fonts queued for loading by `touch()`, processed by `#processQueue()` - #queue = new Map(); + // Maps font key → URL so #purgeUnused() can evict from cache + #urlByKey = new Map(); // Handle for scheduled queue processing (requestIdleCallback or setTimeout) #timeoutId: ReturnType | null = null; @@ -55,28 +70,20 @@ export class AppliedFontsManager { // Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation #pendingType: 'idle' | 'timeout' | null = null; - // 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 - readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation + readonly #PURGE_INTERVAL = 60000; // Reactive status map for Svelte components to track font states statuses = new SvelteMap(); // Starts periodic cleanup timer (browser-only). - constructor() { + constructor( + { cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }: + AppliedFontsManagerDeps = {}, + ) { + // Inject collaborators - defaults provided for production, fakes for testing + this.#cache = cache; + this.#eviction = eviction; + this.#queue = queue; if (typeof window !== 'undefined') { this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); } @@ -92,24 +99,41 @@ export class AppliedFontsManager { if (this.#abortController.signal.aborted) { return; } - try { const now = Date.now(); let hasNewItems = false; for (const config of configs) { const key = generateFontKey(config); - this.#usageTracker.set(key, now); + + // Update last-used timestamp for LRU eviction policy + this.#eviction.touch(key, now); const status = this.statuses.get(key); - if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue; - if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue; - this.#queue.set(key, config); + // Skip fonts that are already loaded or currently loading + if (status === 'loaded' || status === 'loading') { + continue; + } + + // Skip fonts already in the queue (avoid duplicates) + if (this.#queue.has(key)) { + continue; + } + + // Skip error fonts that have exceeded max retry count + if (status === 'error' && this.#queue.isMaxRetriesReached(key)) { + continue; + } + + // Queue this font for loading + this.#queue.enqueue(key, config); hasNewItems = true; } + // Schedule queue processing if we have new items and no existing timer if (hasNewItems && !this.#timeoutId) { + // Prefer requestIdleCallback for better performance (waits for browser idle) if (typeof requestIdleCallback !== 'undefined') { this.#timeoutId = requestIdleCallback( () => this.#processQueue(), @@ -117,6 +141,7 @@ export class AppliedFontsManager { ) as unknown as ReturnType; this.#pendingType = 'idle'; } else { + // Fallback to setTimeout with ~60fps timing this.#timeoutId = setTimeout(() => this.#processQueue(), 16); this.#pendingType = 'timeout'; } @@ -126,12 +151,9 @@ export class AppliedFontsManager { } } - /** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */ - /** Returns true if data-saver mode is enabled (defers non-critical weights). */ #shouldDeferNonCritical(): boolean { - const nav = navigator as any; - return nav.connection?.saveData === true; + return (navigator as any).connection?.saveData === true; } /** @@ -142,71 +164,86 @@ export class AppliedFontsManager { * Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms. */ async #processQueue() { + // Clear timer flags since we're now processing this.#timeoutId = null; this.#pendingType = null; - let entries = Array.from(this.#queue.entries()); - if (!entries.length) return; - this.#queue.clear(); + // Get all queued entries and clear the queue atomically + let entries = this.#queue.flush(); + if (!entries.length) { + return; + } + // In data-saver mode, only load variable fonts and common weights (400, 700) if (this.#shouldDeferNonCritical()) { entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight)); } - // Phase 1: Concurrent fetching (I/O bound, non-blocking) + // Determine optimal concurrent fetches based on network speed (1-4) const concurrency = getEffectiveConcurrency(); const buffers = new Map(); + // ==================== PHASE 1: Concurrent Fetching ==================== + // Fetch multiple font files in parallel since network I/O is non-blocking for (let i = 0; i < entries.length; i += concurrency) { + // Process in chunks based on concurrency limit const chunk = entries.slice(i, i + concurrency); const results = await Promise.allSettled( chunk.map(async ([key, config]) => { this.statuses.set(key, 'loading'); - const buffer = await this.#fetchFontBuffer( - config.url, - this.#abortController.signal, - ); + // Fetch buffer via cache (checks memory → Cache API → network) + const buffer = await this.#cache.get(config.url, this.#abortController.signal); buffers.set(key, buffer); }), ); + // Handle fetch errors - set status and increment retry count for (let j = 0; j < results.length; j++) { if (results[j].status === 'rejected') { const [key, config] = chunk[j]; - console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason); + const reason = (results[j] as PromiseRejectedResult).reason; + if (reason instanceof FontFetchError) { + console.error(`Font fetch failed: ${config.name}`, reason); + } this.statuses.set(key, 'error'); - this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); + this.#queue.incrementRetry(key); } } } - // Phase 2: Sequential parsing (CPU-intensive, yields periodically) + // ==================== PHASE 2: Sequential Parsing ==================== + // Parse buffers one at a time with periodic yields to avoid blocking UI const hasInputPending = !!(navigator as any).scheduling?.isInputPending; let lastYield = performance.now(); - const YIELD_INTERVAL = 8; // ms + const YIELD_INTERVAL = 8; for (const [key, config] of entries) { const buffer = buffers.get(key); + // Skip fonts that failed to fetch in phase 1 if (!buffer) { continue; } try { + // Parse buffer into FontFace and register with document const font = await loadFont(config, buffer); 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; - console.error(`Font parse failed: ${config.name}`, e); - this.statuses.set(key, 'error'); - this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); + if (e instanceof FontParseError) { + console.error(`Font parse failed: ${config.name}`, e); + this.statuses.set(key, 'error'); + this.#queue.incrementRetry(key); + } } + // Yield to main thread if needed (prevents UI blocking) + // Chromium: use isInputPending() for optimal responsiveness + // Others: yield every 8ms as fallback const shouldYield = hasInputPending ? (navigator as any).scheduling.isInputPending({ includeContinuous: true }) - : (performance.now() - lastYield > YIELD_INTERVAL); + : performance.now() - lastYield > YIELD_INTERVAL; if (shouldYield) { await yieldToMainThread(); @@ -215,110 +252,68 @@ export class AppliedFontsManager { } } - /** - * 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); - const cached = await cache.match(url); - if (cached) return cached.arrayBuffer(); - } - } catch { - // 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}`); - - try { - if (typeof caches !== 'undefined') { - const cache = await caches.open(this.#CACHE_NAME); - await cache.put(url, response.clone()); - } - } catch { - // Cache write failed (quota, storage pressure) — return font anyway - } - - return response.arrayBuffer(); - } - /** 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; + // Iterate through all tracked font keys + for (const key of this.#eviction.keys()) { + // Skip fonts that are still within TTL or are pinned + if (!this.#eviction.shouldEvict(key, now)) { + continue; + } + // Remove FontFace from document to free memory const font = this.#loadedFonts.get(key); if (font) document.fonts.delete(font); + // Evict from cache and cleanup URL mapping const url = this.#urlByKey.get(key); if (url) { - this.#buffersByUrl.delete(url); + this.#cache.evict(url); this.#urlByKey.delete(key); } + // Clean up remaining state this.#loadedFonts.delete(key); - this.#usageTracker.delete(key); this.statuses.delete(key); - this.#retryCounts.delete(key); } } /** Returns current loading status for a font, or undefined if never requested. */ getFontStatus(id: string, weight: number, isVariable = false) { try { - const key = generateFontKey({ id, weight, isVariable }); - return this.statuses.get(key); + return this.statuses.get(generateFontKey({ id, weight, isVariable })); } catch (error) { console.error(error); } } /** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */ - pin(id: string, weight: number, isVariable?: boolean): void { - try { - const key = generateFontKey({ id, weight, isVariable: !!isVariable }); - this.#pinnedFonts.add(key); - } catch (error) { - console.error(error); - } + pin(id: string, weight: number, isVariable = false): void { + this.#eviction.pin(generateFontKey({ 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 { - try { - const key = generateFontKey({ id, weight, isVariable: !!isVariable }); - this.#pinnedFonts.delete(key); - } catch (error) { - console.error(error); - } + unpin(id: string, weight: number, isVariable = false): void { + this.#eviction.unpin(generateFontKey({ id, weight, isVariable })); } /** Waits for all fonts to finish loading using document.fonts.ready. */ async ready(): Promise { - if (typeof document === 'undefined') return; + if (typeof document === 'undefined') { + return; + } try { await document.fonts.ready; - } catch { - // document.fonts.ready can reject in some edge cases - // (e.g., document unloaded). Silently resolve. - } + } catch { /* document unloaded */ } } /** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */ destroy() { + // Abort all in-flight network requests this.#abortController.abort(); + // Cancel pending queue processing (idle callback or timeout) if (this.#timeoutId !== null) { if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') { cancelIdleCallback(this.#timeoutId as unknown as number); @@ -329,25 +324,26 @@ export class AppliedFontsManager { this.#pendingType = null; } + // Stop periodic cleanup timer if (this.#intervalId) { clearInterval(this.#intervalId); this.#intervalId = null; } + // Remove all loaded fonts from document if (typeof document !== 'undefined') { for (const font of this.#loadedFonts.values()) { document.fonts.delete(font); } } + // Clear all state and collaborators this.#loadedFonts.clear(); - this.#usageTracker.clear(); - this.#retryCounts.clear(); - this.#buffersByUrl.clear(); this.#urlByKey.clear(); - this.#pinnedFonts.clear(); - this.statuses.clear(); + this.#cache.clear(); + this.#eviction.clear(); this.#queue.clear(); + this.statuses.clear(); } }