diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts new file mode 100644 index 0000000..f2a116e --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -0,0 +1,115 @@ +/** @vitest-environment jsdom */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { AppliedFontsManager } from './appliedFontsStore.svelte'; + +describe('AppliedFontsManager', () => { + let manager: AppliedFontsManager; + let mockFontFaceSet: any; + + beforeEach(() => { + vi.useFakeTimers(); + + mockFontFaceSet = { + add: vi.fn(), + delete: vi.fn(), + }; + + // 1. Properly mock FontFace as a constructor function + const MockFontFace = vi.fn(function(this: any, name: string, url: string) { + this.name = name; + this.url = url; + this.load = vi.fn().mockImplementation(() => { + if (url.includes('fail')) return Promise.reject(new Error('Load failed')); + return Promise.resolve(this); + }); + }); + + vi.stubGlobal('FontFace', MockFontFace); + + // 2. Mock document.fonts safely + Object.defineProperty(document, 'fonts', { + value: mockFontFaceSet, + configurable: true, + writable: true, + }); + + vi.stubGlobal('crypto', { + randomUUID: () => '11111111-1111-1111-1111-111111111111' as any, + }); + + manager = new AppliedFontsManager(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('should batch multiple font requests into a single process', async () => { + const configs = [ + { id: 'lato-400', name: 'Lato', url: 'lato.ttf', weight: 400 }, + { id: 'lato-700', name: 'Lato', url: 'lato-bold.ttf', weight: 700 }, + ]; + + 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 config = { id: 'broken', name: 'Broken', url: 'fail.ttf', 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: 'temp.ttf', weight: 400 }; + + manager.touch([config]); + 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); + + expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined(); + expect(mockFontFaceSet.delete).toHaveBeenCalled(); + }); + + it('should NOT purge fonts that are still being "touched"', async () => { + const config = { id: 'active', name: 'Active', url: 'active.ttf', weight: 400 }; + + manager.touch([config]); + await vi.advanceTimersByTimeAsync(50); + + // Advance 4 minutes + await vi.advanceTimersByTimeAsync(4 * 60 * 1000); + + // Refresh touch + manager.touch([config]); + + // Advance another 2 minutes (Total 6 since start) + await vi.advanceTimersByTimeAsync(2 * 60 * 1000); + + expect(manager.getFontStatus('active', 400)).toBe('loaded'); + }); +}); diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 4629d06..9cb92b7 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -31,155 +31,142 @@ export interface FontConfigRequest { * - Variable fonts: Loaded once per id (covers all weights). * - Static fonts: Loaded per id + weight combination. */ -class AppliedFontsManager { - #usageTracker = new Map(); - #idToBatch = new Map(); - // Changed to HTMLStyleElement - #batchElements = new Map(); +export class AppliedFontsManager { + // Stores the actual FontFace objects for cleanup + #loadedFonts = new Map(); + // Optimization: Map> to avoid O(N^2) scans + #batchToKeys = new Map>(); + // Optimization: Map for reverse lookup + #keyToBatch = new Map(); - #queue = new Map(); // Track config in queue + #usageTracker = new Map(); + #queue = new Map(); #timeoutId: ReturnType | null = null; - #PURGE_INTERVAL = 60000; - #TTL = 5 * 60 * 1000; - #CHUNK_SIZE = 5; // Can be larger since we're just injecting strings + readonly #PURGE_INTERVAL = 60000; + readonly #TTL = 5 * 60 * 1000; + readonly #CHUNK_SIZE = 5; statuses = new SvelteMap(); 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); } } - #getFontKey(config: FontConfigRequest): string { - if (config.isVariable) { - // For variable fonts, the ID is unique enough. - // Loading "Roboto" once covers "Roboto 400" and "Roboto 700" - return `${config.id.toLowerCase()}@vf`; - } - // For static fonts, we still need weight separation - return `${config.id.toLowerCase()}@${config.weight}`; + #getFontKey(id: string, weight: number, isVariable: boolean): string { + return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`; } touch(configs: FontConfigRequest[]) { const now = Date.now(); - configs.forEach(config => { - // Pass the whole config to get key - const key = this.#getFontKey(config); + let hasNewItems = false; + for (const config of configs) { + const key = this.#getFontKey(config.id, config.weight, !!config.isVariable); this.#usageTracker.set(key, now); - // If it's already loaded, we don't need to do anything - if (this.statuses.get(key) === 'loaded') return; - - if (!this.#idToBatch.has(key) && !this.#queue.has(key)) { - this.#queue.set(key, config); - - if (this.#timeoutId) clearTimeout(this.#timeoutId); - this.#timeoutId = setTimeout(() => this.#processQueue(), 50); + if (this.statuses.get(key) === 'loaded' || this.statuses.get(key) === 'loading' || this.#queue.has(key)) { + continue; } - }); - } - getFontStatus(id: string, weight: number, isVariable: boolean = false) { - // Construct a temp config to generate key - const key = this.#getFontKey({ id, weight, name: '', url: '', isVariable }); - return this.statuses.get(key); + 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 + } } #processQueue() { + this.#timeoutId = null; const entries = Array.from(this.#queue.entries()); if (entries.length === 0) return; - for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) { - this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE)); - } - this.#queue.clear(); - this.#timeoutId = null; + + // 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)); + } } - #createBatch(batchEntries: [string, FontConfigRequest][]) { + async #applyBatch(batchEntries: [string, FontConfigRequest][]) { if (typeof document === 'undefined') return; const batchId = crypto.randomUUID(); - let cssRules = ''; + const keysInBatch = new Set(); - batchEntries.forEach(([key, config]) => { + const loadPromises = batchEntries.map(([key, config]) => { this.statuses.set(key, 'loading'); - this.#idToBatch.set(key, batchId); + this.#keyToBatch.set(key, batchId); + keysInBatch.add(key); - // If variable, allow the full weight range. - // If static, lock it to the specific weight. - const weightRule = config.isVariable - ? '100 900' // Variable range (standard coverage) - : config.weight; - const fontFormat = config.isVariable ? 'truetype-variations' : 'truetype'; + // 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}`; - cssRules += ` - @font-face { - font-family: '${config.name}'; - src: url('${config.url}') format('${fontFormat}'); - font-weight: ${weightRule}; - font-style: normal; - font-display: swap; - } - `; - }); + const font = new FontFace(config.name, `url(${config.url})`, { + weight: weightRange, + style: 'normal', + display: 'swap', + }); - const style = document.createElement('style'); - style.dataset.batchId = batchId; - style.innerHTML = cssRules; - document.head.appendChild(style); - this.#batchElements.set(batchId, style); + this.#loadedFonts.set(key, font); - // Use the requested weight for verification, even if the rule covers a range - batchEntries.forEach(([key, config]) => { - document.fonts.load(`${config.weight} 1em "${config.name}"`) - .then(loaded => { - this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error'); + return font.load() + .then(loadedFace => { + document.fonts.add(loadedFace); + this.statuses.set(key, 'loaded'); }) - .catch(() => this.statuses.set(key, 'error')); + .catch(e => { + console.error(`Font load failed: ${config.name}`, e); + this.statuses.set(key, 'error'); + }); }); + + this.#batchToKeys.set(batchId, keysInBatch); + await Promise.allSettled(loadPromises); } + #purgeUnused() { const now = Date.now(); - const batchesToRemove = new Set(); - const keysToRemove: string[] = []; - for (const [key, lastUsed] of this.#usageTracker.entries()) { - if (now - lastUsed > this.#TTL) { - const batchId = this.#idToBatch.get(key); - if (batchId) { - // Check if EVERY font in this batch is expired - const batchKeys = Array.from(this.#idToBatch.entries()) - .filter(([_, bId]) => bId === batchId) - .map(([k]) => k); + // We iterate over batches, not individual fonts, to reduce loops + for (const [batchId, keys] of this.#batchToKeys.entries()) { + let canPurgeBatch = true; - const canDeleteBatch = batchKeys.every(k => { - const lastK = this.#usageTracker.get(k); - return lastK && (now - lastK > this.#TTL); - }); - - if (canDeleteBatch) { - batchesToRemove.add(batchId); - keysToRemove.push(...batchKeys); - } + for (const key of keys) { + const lastUsed = this.#usageTracker.get(key) || 0; + if (now - lastUsed < this.#TTL) { + canPurgeBatch = false; + break; } } + + if (canPurgeBatch) { + keys.forEach(key => { + const font = this.#loadedFonts.get(key); + if (font) document.fonts.delete(font); + + this.#loadedFonts.delete(key); + this.#keyToBatch.delete(key); + this.#usageTracker.delete(key); + this.statuses.delete(key); + }); + this.#batchToKeys.delete(batchId); + } } + } - batchesToRemove.forEach(id => { - this.#batchElements.get(id)?.remove(); - this.#batchElements.delete(id); - }); - - keysToRemove.forEach(k => { - this.#idToBatch.delete(k); - this.#usageTracker.delete(k); - this.statuses.delete(k); - }); + getFontStatus(id: string, weight: number, isVariable = false) { + return this.statuses.get(this.#getFontKey(id, weight, isVariable)); } }