chore(appliedFontsStore): use created collaborators classes
This commit is contained in:
@@ -8,37 +8,40 @@ import {
|
|||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||||
|
import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
|
||||||
|
class FakeBufferCache {
|
||||||
|
async get(_url: string): Promise<ArrayBuffer> {
|
||||||
|
return new ArrayBuffer(8);
|
||||||
|
}
|
||||||
|
evict(_url: string): void {}
|
||||||
|
clear(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
describe('AppliedFontsManager', () => {
|
describe('AppliedFontsManager', () => {
|
||||||
let manager: AppliedFontsManager;
|
let manager: AppliedFontsManager;
|
||||||
let mockFontFaceSet: any;
|
let mockFontFaceSet: any;
|
||||||
let mockFetch: any;
|
let fakeEviction: FontEvictionPolicy;
|
||||||
let failUrls: Set<string>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
failUrls = new Set();
|
fakeEviction = new FontEvictionPolicy({ ttl: 60000 });
|
||||||
|
|
||||||
mockFontFaceSet = {
|
mockFontFaceSet = {
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. Properly mock FontFace as a constructor function
|
|
||||||
// The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
|
|
||||||
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
|
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.bufferOrUrl = bufferOrUrl;
|
this.bufferOrUrl = bufferOrUrl;
|
||||||
this.load = vi.fn().mockImplementation(() => {
|
this.load = vi.fn().mockImplementation(() => {
|
||||||
// For error tests, we track which URLs should fail via failUrls
|
|
||||||
// The fetch mock will have already rejected for those URLs
|
|
||||||
return Promise.resolve(this);
|
return Promise.resolve(this);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.stubGlobal('FontFace', MockFontFace);
|
vi.stubGlobal('FontFace', MockFontFace);
|
||||||
|
|
||||||
// 2. Mock document.fonts safely
|
|
||||||
Object.defineProperty(document, 'fonts', {
|
Object.defineProperty(document, 'fonts', {
|
||||||
value: mockFontFaceSet,
|
value: mockFontFaceSet,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -49,25 +52,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
|
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Mock fetch to return fake ArrayBuffer data
|
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction: fakeEviction });
|
||||||
mockFetch = vi.fn((url: string) => {
|
|
||||||
if (failUrls.has(url)) {
|
|
||||||
return Promise.reject(new Error('Network error'));
|
|
||||||
}
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
|
||||||
clone: () => ({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
|
||||||
}),
|
|
||||||
} as Response);
|
|
||||||
});
|
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
|
||||||
|
|
||||||
manager = new AppliedFontsManager();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -84,29 +69,12 @@ describe('AppliedFontsManager', () => {
|
|||||||
|
|
||||||
manager.touch(configs);
|
manager.touch(configs);
|
||||||
|
|
||||||
// Advance to trigger the 16ms debounced #processQueue
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
|
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
|
||||||
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
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 failUrl = 'https://example.com/fail.ttf';
|
|
||||||
failUrls.add(failUrl);
|
|
||||||
|
|
||||||
const config = { id: 'broken', name: 'Broken', url: failUrl, 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 () => {
|
it('should purge fonts after TTL expires', async () => {
|
||||||
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
||||||
|
|
||||||
@@ -114,9 +82,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
||||||
|
|
||||||
// Move clock forward past TTL (5m) and Purge Interval (1m)
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
// advanceTimersByTimeAsync is key here; it handles the promises inside the interval
|
|
||||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
|
||||||
|
|
||||||
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
||||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
@@ -128,14 +94,11 @@ describe('AppliedFontsManager', () => {
|
|||||||
manager.touch([config]);
|
manager.touch([config]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
// Advance 4 minutes
|
await vi.advanceTimersByTimeAsync(40000);
|
||||||
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
|
||||||
|
|
||||||
// Refresh touch
|
|
||||||
manager.touch([config]);
|
manager.touch([config]);
|
||||||
|
|
||||||
// Advance another 2 minutes (Total 6 since start)
|
await vi.advanceTimersByTimeAsync(20000);
|
||||||
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
|
|
||||||
|
|
||||||
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||||
});
|
});
|
||||||
@@ -143,22 +106,16 @@ describe('AppliedFontsManager', () => {
|
|||||||
it('should serve buffer from memory without calling fetch again', async () => {
|
it('should serve buffer from memory without calling fetch again', async () => {
|
||||||
const config = { id: 'cached', name: 'Cached', url: 'https://example.com/cached.ttf', weight: 400 };
|
const config = { id: 'cached', name: 'Cached', url: 'https://example.com/cached.ttf', weight: 400 };
|
||||||
|
|
||||||
// First load — populates in-memory buffer
|
|
||||||
manager.touch([config]);
|
manager.touch([config]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Simulate eviction by deleting the status entry directly
|
|
||||||
manager.statuses.delete('cached@400');
|
manager.statuses.delete('cached@400');
|
||||||
|
|
||||||
// Second load — should hit in-memory buffer, not network
|
|
||||||
manager.touch([config]);
|
manager.touch([config]);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
||||||
// fetch should still only have been called once (buffer was reused)
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT purge a pinned font after TTL expires', async () => {
|
it('should NOT purge a pinned font after TTL expires', async () => {
|
||||||
@@ -170,8 +127,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
|
|
||||||
manager.pin('pinned', 400);
|
manager.pin('pinned', 400);
|
||||||
|
|
||||||
// Advance past TTL + purge interval
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
|
||||||
|
|
||||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
||||||
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
||||||
@@ -187,8 +143,7 @@ describe('AppliedFontsManager', () => {
|
|||||||
manager.pin('unpinned', 400);
|
manager.pin('unpinned', 400);
|
||||||
manager.unpin('unpinned', 400);
|
manager.unpin('unpinned', 400);
|
||||||
|
|
||||||
// Advance past TTL + purge interval
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
|
||||||
|
|
||||||
expect(manager.getFontStatus('unpinned', 400)).toBeUndefined();
|
expect(manager.getFontStatus('unpinned', 400)).toBeUndefined();
|
||||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import {
|
|||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type FontLoadStatus,
|
type FontLoadStatus,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import {
|
||||||
|
FontFetchError,
|
||||||
|
FontParseError,
|
||||||
|
} from './errors';
|
||||||
|
import { FontBufferCache } from './fontBufferCache/FontBufferCache';
|
||||||
|
import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
import { FontLoadQueue } from './fontLoadQueue/FontLoadQueue';
|
||||||
import {
|
import {
|
||||||
generateFontKey,
|
generateFontKey,
|
||||||
getEffectiveConcurrency,
|
getEffectiveConcurrency,
|
||||||
@@ -10,6 +17,12 @@ import {
|
|||||||
yieldToMainThread,
|
yieldToMainThread,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
|
interface AppliedFontsManagerDeps {
|
||||||
|
cache?: FontBufferCache;
|
||||||
|
eviction?: FontEvictionPolicy;
|
||||||
|
queue?: FontLoadQueue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
|
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
|
||||||
*
|
*
|
||||||
@@ -34,14 +47,16 @@ import {
|
|||||||
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||||
*/
|
*/
|
||||||
export class AppliedFontsManager {
|
export class AppliedFontsManager {
|
||||||
|
// 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`
|
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
|
||||||
#loadedFonts = new Map<string, FontFace>();
|
#loadedFonts = new Map<string, FontFace>();
|
||||||
|
|
||||||
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
|
// Maps font key → URL so #purgeUnused() can evict from cache
|
||||||
#usageTracker = new Map<string, number>();
|
#urlByKey = new Map<string, string>();
|
||||||
|
|
||||||
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
|
|
||||||
#queue = new Map<string, FontLoadRequestConfig>();
|
|
||||||
|
|
||||||
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -55,28 +70,20 @@ export class AppliedFontsManager {
|
|||||||
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||||
#pendingType: 'idle' | 'timeout' | null = null;
|
#pendingType: 'idle' | 'timeout' | null = null;
|
||||||
|
|
||||||
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
|
readonly #PURGE_INTERVAL = 60000;
|
||||||
#retryCounts = new Map<string, number>();
|
|
||||||
|
|
||||||
// In-memory buffer cache keyed by URL — fastest tier, checked before Cache API and network
|
|
||||||
#buffersByUrl = new Map<string, ArrayBuffer>();
|
|
||||||
|
|
||||||
// Maps font key → URL so #purgeUnused() can evict from #buffersByUrl
|
|
||||||
#urlByKey = new Map<string, string>();
|
|
||||||
|
|
||||||
// Fonts currently visible/in-use; purge skips these regardless of TTL
|
|
||||||
#pinnedFonts = new Set<string>();
|
|
||||||
|
|
||||||
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
|
// Reactive status map for Svelte components to track font states
|
||||||
statuses = new SvelteMap<string, FontLoadStatus>();
|
statuses = new SvelteMap<string, FontLoadStatus>();
|
||||||
|
|
||||||
// Starts periodic cleanup timer (browser-only).
|
// Starts periodic cleanup timer (browser-only).
|
||||||
constructor() {
|
constructor(
|
||||||
|
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
||||||
|
AppliedFontsManagerDeps = {},
|
||||||
|
) {
|
||||||
|
// Inject collaborators - defaults provided for production, fakes for testing
|
||||||
|
this.#cache = cache;
|
||||||
|
this.#eviction = eviction;
|
||||||
|
this.#queue = queue;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
}
|
}
|
||||||
@@ -92,24 +99,41 @@ export class AppliedFontsManager {
|
|||||||
if (this.#abortController.signal.aborted) {
|
if (this.#abortController.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let hasNewItems = false;
|
let hasNewItems = false;
|
||||||
|
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const key = generateFontKey(config);
|
const key = generateFontKey(config);
|
||||||
this.#usageTracker.set(key, now);
|
|
||||||
|
// Update last-used timestamp for LRU eviction policy
|
||||||
|
this.#eviction.touch(key, now);
|
||||||
|
|
||||||
const status = this.statuses.get(key);
|
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);
|
// 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;
|
hasNewItems = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule queue processing if we have new items and no existing timer
|
||||||
if (hasNewItems && !this.#timeoutId) {
|
if (hasNewItems && !this.#timeoutId) {
|
||||||
|
// Prefer requestIdleCallback for better performance (waits for browser idle)
|
||||||
if (typeof requestIdleCallback !== 'undefined') {
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
this.#timeoutId = requestIdleCallback(
|
this.#timeoutId = requestIdleCallback(
|
||||||
() => this.#processQueue(),
|
() => this.#processQueue(),
|
||||||
@@ -117,6 +141,7 @@ export class AppliedFontsManager {
|
|||||||
) as unknown as ReturnType<typeof setTimeout>;
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
this.#pendingType = 'idle';
|
this.#pendingType = 'idle';
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback to setTimeout with ~60fps timing
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||||
this.#pendingType = 'timeout';
|
this.#pendingType = 'timeout';
|
||||||
}
|
}
|
||||||
@@ -126,12 +151,9 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
|
|
||||||
|
|
||||||
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
||||||
#shouldDeferNonCritical(): boolean {
|
#shouldDeferNonCritical(): boolean {
|
||||||
const nav = navigator as any;
|
return (navigator as any).connection?.saveData === true;
|
||||||
return nav.connection?.saveData === true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,71 +164,86 @@ export class AppliedFontsManager {
|
|||||||
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
|
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
|
||||||
*/
|
*/
|
||||||
async #processQueue() {
|
async #processQueue() {
|
||||||
|
// Clear timer flags since we're now processing
|
||||||
this.#timeoutId = null;
|
this.#timeoutId = null;
|
||||||
this.#pendingType = null;
|
this.#pendingType = null;
|
||||||
|
|
||||||
let entries = Array.from(this.#queue.entries());
|
// Get all queued entries and clear the queue atomically
|
||||||
if (!entries.length) return;
|
let entries = this.#queue.flush();
|
||||||
this.#queue.clear();
|
if (!entries.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
||||||
if (this.#shouldDeferNonCritical()) {
|
if (this.#shouldDeferNonCritical()) {
|
||||||
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
|
// Determine optimal concurrent fetches based on network speed (1-4)
|
||||||
const concurrency = getEffectiveConcurrency();
|
const concurrency = getEffectiveConcurrency();
|
||||||
const buffers = new Map<string, ArrayBuffer>();
|
const buffers = new Map<string, ArrayBuffer>();
|
||||||
|
|
||||||
|
// ==================== PHASE 1: Concurrent Fetching ====================
|
||||||
|
// Fetch multiple font files in parallel since network I/O is non-blocking
|
||||||
for (let i = 0; i < entries.length; i += concurrency) {
|
for (let i = 0; i < entries.length; i += concurrency) {
|
||||||
|
// Process in chunks based on concurrency limit
|
||||||
const chunk = entries.slice(i, i + concurrency);
|
const chunk = entries.slice(i, i + concurrency);
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
chunk.map(async ([key, config]) => {
|
chunk.map(async ([key, config]) => {
|
||||||
this.statuses.set(key, 'loading');
|
this.statuses.set(key, 'loading');
|
||||||
const buffer = await this.#fetchFontBuffer(
|
// Fetch buffer via cache (checks memory → Cache API → network)
|
||||||
config.url,
|
const buffer = await this.#cache.get(config.url, this.#abortController.signal);
|
||||||
this.#abortController.signal,
|
|
||||||
);
|
|
||||||
buffers.set(key, buffer);
|
buffers.set(key, buffer);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle fetch errors - set status and increment retry count
|
||||||
for (let j = 0; j < results.length; j++) {
|
for (let j = 0; j < results.length; j++) {
|
||||||
if (results[j].status === 'rejected') {
|
if (results[j].status === 'rejected') {
|
||||||
const [key, config] = chunk[j];
|
const [key, config] = chunk[j];
|
||||||
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
|
const reason = (results[j] as PromiseRejectedResult).reason;
|
||||||
|
if (reason instanceof FontFetchError) {
|
||||||
|
console.error(`Font fetch failed: ${config.name}`, reason);
|
||||||
|
}
|
||||||
this.statuses.set(key, 'error');
|
this.statuses.set(key, 'error');
|
||||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
this.#queue.incrementRetry(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
|
// ==================== PHASE 2: Sequential Parsing ====================
|
||||||
|
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
||||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||||
let lastYield = performance.now();
|
let lastYield = performance.now();
|
||||||
const YIELD_INTERVAL = 8; // ms
|
const YIELD_INTERVAL = 8;
|
||||||
|
|
||||||
for (const [key, config] of entries) {
|
for (const [key, config] of entries) {
|
||||||
const buffer = buffers.get(key);
|
const buffer = buffers.get(key);
|
||||||
|
// Skip fonts that failed to fetch in phase 1
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Parse buffer into FontFace and register with document
|
||||||
const font = await loadFont(config, buffer);
|
const font = await loadFont(config, buffer);
|
||||||
this.#loadedFonts.set(key, font);
|
this.#loadedFonts.set(key, font);
|
||||||
this.#buffersByUrl.set(config.url, buffer);
|
|
||||||
this.#urlByKey.set(key, config.url);
|
this.#urlByKey.set(key, config.url);
|
||||||
this.statuses.set(key, 'loaded');
|
this.statuses.set(key, 'loaded');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.name === 'AbortError') continue;
|
if (e instanceof FontParseError) {
|
||||||
console.error(`Font parse failed: ${config.name}`, e);
|
console.error(`Font parse failed: ${config.name}`, e);
|
||||||
this.statuses.set(key, 'error');
|
this.statuses.set(key, 'error');
|
||||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
this.#queue.incrementRetry(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yield to main thread if needed (prevents UI blocking)
|
||||||
|
// Chromium: use isInputPending() for optimal responsiveness
|
||||||
|
// Others: yield every 8ms as fallback
|
||||||
const shouldYield = hasInputPending
|
const shouldYield = hasInputPending
|
||||||
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||||
: (performance.now() - lastYield > YIELD_INTERVAL);
|
: performance.now() - lastYield > YIELD_INTERVAL;
|
||||||
|
|
||||||
if (shouldYield) {
|
if (shouldYield) {
|
||||||
await yieldToMainThread();
|
await yieldToMainThread();
|
||||||
@@ -215,110 +252,68 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches font with three-tier lookup: in-memory buffer → Cache API → network.
|
|
||||||
* Cache failures (private browsing, quota limits) are silently ignored.
|
|
||||||
*/
|
|
||||||
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
|
||||||
// Tier 1: in-memory buffer (fastest, no I/O)
|
|
||||||
const inMemory = this.#buffersByUrl.get(url);
|
|
||||||
if (inMemory) return inMemory;
|
|
||||||
|
|
||||||
// Tier 2: Cache API
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tier 3: 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. Pinned fonts are never evicted. */
|
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
|
||||||
#purgeUnused() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, lastUsed] of this.#usageTracker) {
|
// Iterate through all tracked font keys
|
||||||
if (now - lastUsed < this.#TTL) continue;
|
for (const key of this.#eviction.keys()) {
|
||||||
if (this.#pinnedFonts.has(key)) continue;
|
// 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);
|
const font = this.#loadedFonts.get(key);
|
||||||
if (font) document.fonts.delete(font);
|
if (font) document.fonts.delete(font);
|
||||||
|
|
||||||
|
// Evict from cache and cleanup URL mapping
|
||||||
const url = this.#urlByKey.get(key);
|
const url = this.#urlByKey.get(key);
|
||||||
if (url) {
|
if (url) {
|
||||||
this.#buffersByUrl.delete(url);
|
this.#cache.evict(url);
|
||||||
this.#urlByKey.delete(key);
|
this.#urlByKey.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up remaining state
|
||||||
this.#loadedFonts.delete(key);
|
this.#loadedFonts.delete(key);
|
||||||
this.#usageTracker.delete(key);
|
|
||||||
this.statuses.delete(key);
|
this.statuses.delete(key);
|
||||||
this.#retryCounts.delete(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns current loading status for a font, or undefined if never requested. */
|
/** Returns current loading status for a font, or undefined if never requested. */
|
||||||
getFontStatus(id: string, weight: number, isVariable = false) {
|
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||||
try {
|
try {
|
||||||
const key = generateFontKey({ id, weight, isVariable });
|
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
|
||||||
return this.statuses.get(key);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
|
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
|
||||||
pin(id: string, weight: number, isVariable?: boolean): void {
|
pin(id: string, weight: number, isVariable = false): void {
|
||||||
try {
|
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
|
||||||
const key = generateFontKey({ id, weight, isVariable: !!isVariable });
|
|
||||||
this.#pinnedFonts.add(key);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
|
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
|
||||||
unpin(id: string, weight: number, isVariable?: boolean): void {
|
unpin(id: string, weight: number, isVariable = false): void {
|
||||||
try {
|
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
|
||||||
const key = generateFontKey({ id, weight, isVariable: !!isVariable });
|
|
||||||
this.#pinnedFonts.delete(key);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
||||||
async ready(): Promise<void> {
|
async ready(): Promise<void> {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await document.fonts.ready;
|
await document.fonts.ready;
|
||||||
} catch {
|
} catch { /* document unloaded */ }
|
||||||
// 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. */
|
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
|
||||||
destroy() {
|
destroy() {
|
||||||
|
// Abort all in-flight network requests
|
||||||
this.#abortController.abort();
|
this.#abortController.abort();
|
||||||
|
|
||||||
|
// Cancel pending queue processing (idle callback or timeout)
|
||||||
if (this.#timeoutId !== null) {
|
if (this.#timeoutId !== null) {
|
||||||
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
|
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
|
||||||
cancelIdleCallback(this.#timeoutId as unknown as number);
|
cancelIdleCallback(this.#timeoutId as unknown as number);
|
||||||
@@ -329,25 +324,26 @@ export class AppliedFontsManager {
|
|||||||
this.#pendingType = null;
|
this.#pendingType = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop periodic cleanup timer
|
||||||
if (this.#intervalId) {
|
if (this.#intervalId) {
|
||||||
clearInterval(this.#intervalId);
|
clearInterval(this.#intervalId);
|
||||||
this.#intervalId = null;
|
this.#intervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove all loaded fonts from document
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
for (const font of this.#loadedFonts.values()) {
|
for (const font of this.#loadedFonts.values()) {
|
||||||
document.fonts.delete(font);
|
document.fonts.delete(font);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear all state and collaborators
|
||||||
this.#loadedFonts.clear();
|
this.#loadedFonts.clear();
|
||||||
this.#usageTracker.clear();
|
|
||||||
this.#retryCounts.clear();
|
|
||||||
this.#buffersByUrl.clear();
|
|
||||||
this.#urlByKey.clear();
|
this.#urlByKey.clear();
|
||||||
this.#pinnedFonts.clear();
|
this.#cache.clear();
|
||||||
this.statuses.clear();
|
this.#eviction.clear();
|
||||||
this.#queue.clear();
|
this.#queue.clear();
|
||||||
|
this.statuses.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user