import { SvelteMap } from 'svelte/reactivity'; import { type FontLoadRequestConfig, type FontLoadStatus, } from '../../types'; import { FontFetchError, FontParseError, } from './errors'; import { generateFontKey, getEffectiveConcurrency, loadFont, yieldToMainThread, } from './utils'; import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache'; import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy'; import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue'; /** * How often the periodic eviction sweep runs. */ const PURGE_INTERVAL_MS = 60000; /** * Timeout for `requestIdleCallback`. After this elapses, the callback is * forced to run regardless of whether the browser is idle. */ const IDLE_CALLBACK_TIMEOUT_MS = 150; /** * setTimeout fallback delay when `requestIdleCallback` is unavailable. * ~16ms ≈ one frame at 60fps. */ const SCHEDULE_FALLBACK_MS = 16; /** * How often the parse loop yields back to the main thread when the browser * does not provide `isInputPending` (non-Chromium fallback). */ const YIELD_INTERVAL_MS = 8; /** * Font weights treated as "critical" in data-saver mode. Other weights are * skipped to reduce network usage; variable fonts bypass this filter. */ const CRITICAL_FONT_WEIGHTS = [400, 700]; interface FontLifecycleManagerDeps { cache?: FontBufferCache; eviction?: FontEvictionPolicy; queue?: FontLoadQueue; } /** * Manages web font loading with caching, adaptive concurrency, and automatic cleanup. * * **Two-Phase Loading Strategy:** * 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking) * 2. *Sequential Parsing*: Buffers parsed into FontFace objects one at a time with periodic yields * * **Yielding Strategy:** * - Chromium: Yields only when user input is pending (via `scheduler.yield()` + `isInputPending()`) * - Others: Time-based fallback, yields every 8ms * * **Network Adaptation:** * - 2G: 1 concurrent request, 3G: 2, 4G+: 4 (via Network Information API) * - Respects `saveData` mode to defer non-critical weights * * **Cache Integration:** Cache API with best-effort fallback (handles private browsing, quota limits) * * **Cleanup:** LRU-style eviction after 5 minutes of inactivity; cleanup runs every 60 seconds * * **Font Identity:** Variable fonts use `{id}@vf`, static fonts use `{id}@{weight}` * * **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API */ export class FontLifecycleManager { // 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(); // 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; // Interval handle for periodic cleanup (runs every PURGE_INTERVAL) #intervalId: ReturnType | null = null; // AbortController for canceling in-flight fetches on destroy #abortController = new AbortController(); // Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation #pendingType: 'idle' | 'timeout' | null = null; // Reactive status map for Svelte components to track font states statuses = new SvelteMap(); // Starts periodic cleanup timer (browser-only). constructor( { cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }: FontLifecycleManagerDeps = {}, ) { // 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(), PURGE_INTERVAL_MS); } } /** * Requests fonts to be loaded. Updates usage tracking and queues new fonts. * * Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES. * Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms). */ touch(configs: FontLoadRequestConfig[]) { if (this.#abortController.signal.aborted) { return; } try { const now = Date.now(); let hasNewItems = false; for (const config of configs) { const key = generateFontKey(config); // Update last-used timestamp for LRU eviction policy this.#eviction.touch(key, now); const status = this.statuses.get(key); // 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; } if (hasNewItems && !this.#timeoutId) { this.#scheduleProcessing(); } } catch (error) { console.error(error); } } /** * Schedules `#processQueue()` via `requestIdleCallback` (150ms timeout) when available, * falling back to `setTimeout(16ms)` for ~60fps timing. */ #scheduleProcessing(): void { if (typeof requestIdleCallback !== 'undefined') { this.#timeoutId = requestIdleCallback( () => this.#processQueue(), { timeout: IDLE_CALLBACK_TIMEOUT_MS }, ) as unknown as ReturnType; this.#pendingType = 'idle'; } else { this.#timeoutId = setTimeout(() => this.#processQueue(), SCHEDULE_FALLBACK_MS); this.#pendingType = 'timeout'; } } /** * Returns true if data-saver mode is enabled (defers non-critical weights). */ #shouldDeferNonCritical(): boolean { return (navigator as any).connection?.saveData === true; } /** * Processes queued fonts in two phases: * 1. Concurrent fetching (network I/O, non-blocking) * 2. Sequential parsing with periodic yields (CPU-intensive, can block UI) * * 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; // 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 || CRITICAL_FONT_WEIGHTS.includes(c.weight)); } // Determine optimal concurrent fetches based on network speed (1-4) const concurrency = getEffectiveConcurrency(); const buffers = new Map(); // Fetch multiple font files in parallel since network I/O is non-blocking for (let i = 0; i < entries.length; i += concurrency) { await this.#fetchChunk(entries.slice(i, i + concurrency), buffers); } // Parse buffers one at a time with periodic yields to avoid blocking UI const hasInputPending = !!(navigator as any).scheduling?.isInputPending; let lastYield = performance.now(); for (const [key, config] of entries) { const buffer = buffers.get(key); // Skip fonts that failed to fetch in phase 1 if (!buffer) { continue; } await this.#processFont(key, config, buffer); // 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_MS; if (shouldYield) { await yieldToMainThread(); lastYield = performance.now(); } } } /** * Fetches a chunk of fonts concurrently and populates `buffers` with successful results. * Each promise carries its own key and config so results need no index correlation. * Aborted fetches are silently skipped; other errors set status to `'error'` and increment retry. */ async #fetchChunk( chunk: Array<[string, FontLoadRequestConfig]>, buffers: Map, ): Promise { const results = await Promise.all( chunk.map(async ([key, config]) => { this.statuses.set(key, 'loading'); try { const buffer = await this.#cache.get(config.url, this.#abortController.signal); buffers.set(key, buffer); return { ok: true as const, key }; } catch (reason) { return { ok: false as const, key, config, reason }; } }), ); for (const result of results) { if (result.ok) { continue; } const { key, config, reason } = result; const isAbort = reason instanceof FontFetchError && reason.cause instanceof Error && reason.cause.name === 'AbortError'; if (isAbort) { continue; } if (reason instanceof FontFetchError) { console.error(`Font fetch failed: ${config.name}`, reason); } this.statuses.set(key, 'error'); this.#queue.incrementRetry(key); } } /** * Parses a fetched buffer into a {@link FontFace}, registers it with `document.fonts`, * and updates reactive status. On failure, sets status to `'error'` and increments the retry count. */ async #processFont(key: string, config: FontLoadRequestConfig, buffer: ArrayBuffer): Promise { try { const font = await loadFont(config, buffer); this.#loadedFonts.set(key, font); this.#urlByKey.set(key, config.url); this.statuses.set(key, 'loaded'); } catch (e) { if (e instanceof FontParseError) { console.error(`Font parse failed: ${config.name}`, e); this.statuses.set(key, 'error'); this.#queue.incrementRetry(key); } } } /** * Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */ #purgeUnused() { const now = Date.now(); // 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.#cache.evict(url); this.#urlByKey.delete(key); } // Clean up remaining state this.#loadedFonts.delete(key); this.statuses.delete(key); this.#eviction.remove(key); } } /** * Returns current loading status for a font, or undefined if never requested. */ getFontStatus(id: string, weight: number, isVariable = false) { try { 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 = 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 = 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; } try { await document.fonts.ready; } 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); } else { clearTimeout(this.#timeoutId); } this.#timeoutId = null; 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.#urlByKey.clear(); this.#cache.clear(); this.#eviction.clear(); this.#queue.clear(); this.statuses.clear(); } } /** * Singleton instance — use throughout the application for unified font loading state. */ export const fontLifecycleManager = new FontLifecycleManager();