fe07c60dd4
Replace the hand-rolled let _x / getX / __resetX boilerplate with the createSingleton helper in all nine remaining singleton stores. Exposed accessor names (getX, __resetX) are unchanged, so consumers and specs are unaffected. Teardown wired to each stores destroy() where it has one (fontCatalog, fontLifecycle, typography, availableFilter, theme, layout, scrollBreadcrumbs); sort and appliedFilter have no teardown. Also merges layoutStores duplicate $shared/lib imports.
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
import { createSingleton } from '$shared/lib/helpers/createSingleton/createSingleton';
|
|
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<string, FontFace>();
|
|
|
|
// Maps font key → URL so #purgeUnused() can evict from cache
|
|
#urlByKey = new Map<string, string>();
|
|
|
|
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// 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;
|
|
|
|
// Reactive status map for Svelte components to track font states
|
|
statuses = new SvelteMap<string, FontLoadStatus>();
|
|
|
|
// 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<typeof setTimeout>;
|
|
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<string, ArrayBuffer>();
|
|
|
|
// 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<string, ArrayBuffer>,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* App-wide font lifecycle manager, created on first access. Lazy so its
|
|
* AbortController / FontFace bookkeeping isn't set up at module load.
|
|
*/
|
|
const fontLifecycleManager = createSingleton(
|
|
() => new FontLifecycleManager(),
|
|
instance => instance.destroy(),
|
|
);
|
|
|
|
export const getFontLifecycleManager = fontLifecycleManager.get;
|
|
|
|
// test-only reset, so specs don't share loaded-font/eviction state
|
|
export const __resetFontLifecycleManager = fontLifecycleManager.reset;
|