feat(appliedFontStore): use FontFace constructor, improve the performance and add test coverage for basic logic
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>();
|
||||
#idToBatch = new Map<string, string>();
|
||||
// Changed to HTMLStyleElement
|
||||
#batchElements = new Map<string, HTMLStyleElement>();
|
||||
export class AppliedFontsManager {
|
||||
// Stores the actual FontFace objects for cleanup
|
||||
#loadedFonts = new Map<string, FontFace>();
|
||||
// Optimization: Map<batchId, Set<fontKeys>> to avoid O(N^2) scans
|
||||
#batchToKeys = new Map<string, Set<string>>();
|
||||
// Optimization: Map<fontKey, batchId> for reverse lookup
|
||||
#keyToBatch = new Map<string, string>();
|
||||
|
||||
#queue = new Map<string, FontConfigRequest>(); // Track config in queue
|
||||
#usageTracker = new Map<string, number>();
|
||||
#queue = new Map<string, FontConfigRequest>();
|
||||
#timeoutId: ReturnType<typeof setTimeout> | 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<string, FontStatus>();
|
||||
|
||||
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<string>();
|
||||
|
||||
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<string>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user