feat(appliedFontsStore): add extensive documentation, implement optimization and usage of browser apis to ensure flawless ux and avoid ui freezing
This commit is contained in:
@@ -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<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>();
|
||||
|
||||
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
|
||||
#usageTracker = new Map<string, number>();
|
||||
|
||||
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
|
||||
#queue = new Map<string, FontConfigRequest>();
|
||||
|
||||
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
||||
#timeoutId: ReturnType<typeof setTimeout> | 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<typeof setInterval> | 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<string, number>();
|
||||
|
||||
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<string, FontStatus>();
|
||||
|
||||
// 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<typeof setTimeout>;
|
||||
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<void> {
|
||||
// @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<void>(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<string, ArrayBuffer>();
|
||||
|
||||
const batchId = crypto.randomUUID();
|
||||
const keysInBatch = new Set<string>();
|
||||
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<ArrayBuffer> {
|
||||
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<void> {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user