diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 6a6c61a..2032a52 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -1,67 +1,109 @@ import { SvelteMap } from 'svelte/reactivity'; +/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */ export type FontStatus = 'loading' | 'loaded' | 'error'; +/** Configuration for a font load request. */ export interface FontConfigRequest { /** - * Font id + * Unique identifier for the font (e.g., "lato", "roboto"). */ id: string; /** - * Real font name (e.g. "Lato") + * Actual font family name recognized by the browser (e.g., "Lato", "Roboto"). */ name: string; /** - * The .ttf URL + * URL pointing to the font file (typically .ttf or .woff2). */ url: string; /** - * Font weight + * Numeric weight (100-900). Variable fonts load once per ID regardless of weight. */ weight: number; /** - * Flag of the variable weight + * Variable fonts load once per ID; static fonts load per weight. */ isVariable?: boolean; } /** - * Manager that handles loading of fonts. - * Logic: - * - Variable fonts: Loaded once per id (covers all weights). - * - Static fonts: Loaded per id + weight combination. + * 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 AppliedFontsManager { - // Stores the actual FontFace objects for cleanup + // Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf` #loadedFonts = new Map(); - // Optimization: Map> to avoid O(N^2) scans - #batchToKeys = new Map>(); - // Optimization: Map for reverse lookup - #keyToBatch = 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(); + + // Handle for scheduled queue processing (requestIdleCallback or setTimeout) #timeoutId: ReturnType | null = null; - readonly #PURGE_INTERVAL = 60000; - readonly #TTL = 5 * 60 * 1000; - readonly #CHUNK_SIZE = 5; + // 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; + + // Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped + #retryCounts = new Map(); + + 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 + + // Reactive status map for Svelte components to track font states statuses = new SvelteMap(); + // Starts periodic cleanup timer (browser-only). constructor() { if (typeof window !== 'undefined') { - // Using a weak reference style approach isn't possible for DOM, - // so we stick to the interval but make it highly efficient. - setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); + this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); } } + // Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static. #getFontKey(id: string, weight: number, isVariable: boolean): string { return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`; } + /** + * 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: FontConfigRequest[]) { + if (this.#abortController.signal.aborted) return; + const now = Date.now(); let hasNewItems = false; @@ -69,105 +111,244 @@ export class AppliedFontsManager { const key = this.#getFontKey(config.id, config.weight, !!config.isVariable); this.#usageTracker.set(key, now); - if (this.statuses.get(key) === 'loaded' || this.statuses.get(key) === 'loading' || this.#queue.has(key)) { - continue; - } + 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); hasNewItems = true; } - // IMPROVEMENT: Only trigger timer if not already pending if (hasNewItems && !this.#timeoutId) { - this.#timeoutId = setTimeout(() => this.#processQueue(), 16); // ~1 frame delay + if (typeof requestIdleCallback !== 'undefined') { + this.#timeoutId = requestIdleCallback( + () => this.#processQueue(), + { timeout: 150 }, + ) as unknown as ReturnType; + this.#pendingType = 'idle'; + } else { + this.#timeoutId = setTimeout(() => this.#processQueue(), 16); + this.#pendingType = 'timeout'; + } } } - #processQueue() { - this.#timeoutId = null; - const entries = Array.from(this.#queue.entries()); - if (entries.length === 0) return; + /** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */ + async #yieldToMain(): Promise { + // @ts-expect-error - scheduler not in TypeScript lib yet + if (typeof scheduler !== 'undefined' && 'yield' in scheduler) { + // @ts-expect-error - scheduler.yield not in TypeScript lib yet + await scheduler.yield(); + } else { + await new Promise(resolve => { + const ch = new MessageChannel(); + ch.port1.onmessage = () => resolve(); + ch.port2.postMessage(null); + }); + } + } + /** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */ + #getEffectiveConcurrency(): number { + const nav = navigator as any; + const conn = nav.connection; + if (!conn) return 4; + + switch (conn.effectiveType) { + case 'slow-2g': + case '2g': + return 1; + case '3g': + return 2; + default: + return 4; + } + } + + /** Returns true if data-saver mode is enabled (defers non-critical weights). */ + #shouldDeferNonCritical(): boolean { + const nav = navigator as any; + return nav.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() { + this.#timeoutId = null; + this.#pendingType = null; + + let entries = Array.from(this.#queue.entries()); + if (!entries.length) return; this.#queue.clear(); - // Process in chunks to keep the UI responsive - for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) { - this.#applyBatch(entries.slice(i, i + this.#CHUNK_SIZE)); + if (this.#shouldDeferNonCritical()) { + entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight)); } - } - async #applyBatch(batchEntries: [string, FontConfigRequest][]) { - if (typeof document === 'undefined') return; + // Phase 1: Concurrent fetching (I/O bound, non-blocking) + const concurrency = this.#getEffectiveConcurrency(); + const buffers = new Map(); - const batchId = crypto.randomUUID(); - const keysInBatch = new Set(); + for (let i = 0; i < entries.length; i += concurrency) { + 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, + ); + buffers.set(key, buffer); + }), + ); - const loadPromises = batchEntries.map(([key, config]) => { - this.statuses.set(key, 'loading'); - this.#keyToBatch.set(key, batchId); - keysInBatch.add(key); - - // Use a unique internal family name to prevent collisions - // while keeping the "real" name for the browser to resolve weight/style. - const internalName = `f_${config.id}`; - const weightRange = config.isVariable ? '100 900' : `${config.weight}`; - - const font = new FontFace(config.name, `url(${config.url}) format('woff2')`, { - weight: weightRange, - style: 'normal', - display: 'swap', - }); - - this.#loadedFonts.set(key, font); - - return font.load() - .then(loadedFace => { - document.fonts.add(loadedFace); - this.statuses.set(key, 'loaded'); - }) - .catch(e => { - console.error(`Font load failed: ${config.name}`, e); + 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); this.statuses.set(key, 'error'); - }); - }); - - this.#batchToKeys.set(batchId, keysInBatch); - await Promise.allSettled(loadPromises); - } - - #purgeUnused() { - const now = Date.now(); - - // We iterate over batches, not individual fonts, to reduce loops - for (const [batchId, keys] of this.#batchToKeys.entries()) { - let canPurgeBatch = true; - - for (const key of keys) { - const lastUsed = this.#usageTracker.get(key) || 0; - if (now - lastUsed < this.#TTL) { - canPurgeBatch = false; - break; + this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1); } } + } - if (canPurgeBatch) { - keys.forEach(key => { - const font = this.#loadedFonts.get(key); - if (font) document.fonts.delete(font); + // Phase 2: Sequential parsing (CPU-intensive, yields periodically) + const hasInputPending = !!(navigator as any).scheduling?.isInputPending; + let lastYield = performance.now(); + const YIELD_INTERVAL = 8; // ms - this.#loadedFonts.delete(key); - this.#keyToBatch.delete(key); - this.#usageTracker.delete(key); - this.statuses.delete(key); + for (const [key, config] of entries) { + const buffer = buffers.get(key); + if (!buffer) continue; + + try { + const weightRange = config.isVariable ? '100 900' : `${config.weight}`; + const font = new FontFace(config.name, buffer, { + weight: weightRange, + style: 'normal', + display: 'swap', }); - this.#batchToKeys.delete(batchId); + await font.load(); + document.fonts.add(font); + this.#loadedFonts.set(key, font); + 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); + } + + const shouldYield = hasInputPending + ? (navigator as any).scheduling.isInputPending({ includeContinuous: true }) + : (performance.now() - lastYield > YIELD_INTERVAL); + + if (shouldYield) { + await this.#yieldToMain(); + lastYield = performance.now(); } } } + /** + * Fetches font with cache-aside pattern: checks Cache API first, falls back to network. + * Cache failures (private browsing, quota limits) are silently ignored. + */ + async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise { + 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 + } + + 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. */ + #purgeUnused() { + const now = Date.now(); + for (const [key, lastUsed] of this.#usageTracker) { + if (now - lastUsed < this.#TTL) continue; + + const font = this.#loadedFonts.get(key); + if (font) document.fonts.delete(font); + + 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) { return this.statuses.get(this.#getFontKey(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.fonts.ready can reject in some edge cases + // (e.g., document unloaded). Silently resolve. + } + } + + /** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */ + destroy() { + this.#abortController.abort(); + + 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; + } + + if (this.#intervalId) { + clearInterval(this.#intervalId); + this.#intervalId = null; + } + + if (typeof document !== 'undefined') { + for (const font of this.#loadedFonts.values()) { + document.fonts.delete(font); + } + } + + this.#loadedFonts.clear(); + this.#usageTracker.clear(); + this.#retryCounts.clear(); + this.statuses.clear(); + this.#queue.clear(); + } } +/** Singleton instance — use throughout the application for unified font loading state. */ export const appliedFontsManager = new AppliedFontsManager();