Compare commits
5 Commits
d21de1bf78
...
e88cca9289
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e88cca9289 | ||
|
|
d4cf6764b4 | ||
|
|
5a065ae5a1 | ||
|
|
20110168f2 | ||
|
|
f88729cc77 |
@@ -1,15 +1,10 @@
|
|||||||
/** @vitest-environment jsdom */
|
/** @vitest-environment jsdom */
|
||||||
import {
|
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||||
|
import { FontFetchError } from './errors';
|
||||||
import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy';
|
import { FontEvictionPolicy } from './fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
|
||||||
|
// ── Fake collaborators ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class FakeBufferCache {
|
class FakeBufferCache {
|
||||||
async get(_url: string): Promise<ArrayBuffer> {
|
async get(_url: string): Promise<ArrayBuffer> {
|
||||||
return new ArrayBuffer(8);
|
return new ArrayBuffer(8);
|
||||||
@@ -18,29 +13,36 @@ class FakeBufferCache {
|
|||||||
clear(): void {}
|
clear(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */
|
||||||
|
class FailingBufferCache {
|
||||||
|
async get(url: string): Promise<never> {
|
||||||
|
throw new FontFetchError(url, new Error('network error'), 500);
|
||||||
|
}
|
||||||
|
evict(_url: string): void {}
|
||||||
|
clear(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
url: `https://example.com/${id}.woff2`,
|
||||||
|
weight: 400,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Suite ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('AppliedFontsManager', () => {
|
describe('AppliedFontsManager', () => {
|
||||||
let manager: AppliedFontsManager;
|
let manager: AppliedFontsManager;
|
||||||
let mockFontFaceSet: any;
|
let eviction: FontEvictionPolicy;
|
||||||
let fakeEviction: FontEvictionPolicy;
|
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
fakeEviction = new FontEvictionPolicy({ ttl: 60000 });
|
eviction = new FontEvictionPolicy({ ttl: 60000 });
|
||||||
|
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
|
||||||
mockFontFaceSet = {
|
|
||||||
add: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
|
|
||||||
this.name = name;
|
|
||||||
this.bufferOrUrl = bufferOrUrl;
|
|
||||||
this.load = vi.fn().mockImplementation(() => {
|
|
||||||
return Promise.resolve(this);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.stubGlobal('FontFace', MockFontFace);
|
|
||||||
|
|
||||||
Object.defineProperty(document, 'fonts', {
|
Object.defineProperty(document, 'fonts', {
|
||||||
value: mockFontFaceSet,
|
value: mockFontFaceSet,
|
||||||
@@ -48,11 +50,14 @@ describe('AppliedFontsManager', () => {
|
|||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.stubGlobal('crypto', {
|
const MockFontFace = vi.fn(function(this: any, name: string, buffer: BufferSource) {
|
||||||
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
|
this.name = name;
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.load = vi.fn().mockResolvedValue(this);
|
||||||
});
|
});
|
||||||
|
vi.stubGlobal('FontFace', MockFontFace);
|
||||||
|
|
||||||
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction: fakeEviction });
|
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -61,108 +66,267 @@ describe('AppliedFontsManager', () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should batch multiple font requests into a single process', async () => {
|
// ── touch() ───────────────────────────────────────────────────────────────
|
||||||
const configs = [
|
|
||||||
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
|
|
||||||
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
|
|
||||||
];
|
|
||||||
|
|
||||||
manager.touch(configs);
|
describe('touch()', () => {
|
||||||
|
it('queues and loads a new font', async () => {
|
||||||
|
manager.touch([makeConfig('roboto')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
expect(manager.getFontStatus('roboto', 400)).toBe('loaded');
|
||||||
|
});
|
||||||
|
|
||||||
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
|
it('batches multiple fonts into a single queue flush', async () => {
|
||||||
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
manager.touch([makeConfig('lato'), makeConfig('inter')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fonts that are already loaded', async () => {
|
||||||
|
manager.touch([makeConfig('lato')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
manager.touch([makeConfig('lato')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fonts that are currently loading', async () => {
|
||||||
|
manager.touch([makeConfig('lato')]);
|
||||||
|
// simulate loading state before queue drains
|
||||||
|
manager.statuses.set('lato@400', 'loading');
|
||||||
|
manager.touch([makeConfig('lato')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips fonts that have exhausted retries', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
|
// exhaust all 3 retries
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
failManager.statuses.delete('broken@400');
|
||||||
|
failManager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
failManager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
||||||
|
expect(mockFontFaceSet.add).not.toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing after manager is destroyed', async () => {
|
||||||
|
manager.destroy();
|
||||||
|
manager.touch([makeConfig('roboto')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.statuses.size).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should purge fonts after TTL expires', async () => {
|
// ── queue processing ──────────────────────────────────────────────────────
|
||||||
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
describe('queue processing', () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
it('filters non-critical weights in data-saver mode', async () => {
|
||||||
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
(navigator as any).connection = { saveData: true };
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(61000);
|
manager.touch([
|
||||||
|
makeConfig('light', { weight: 300 }),
|
||||||
|
makeConfig('regular', { weight: 400 }),
|
||||||
|
makeConfig('bold', { weight: 700 }),
|
||||||
|
]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
expect(manager.getFontStatus('light', 300)).toBeUndefined();
|
||||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
expect(manager.getFontStatus('regular', 400)).toBe('loaded');
|
||||||
|
expect(manager.getFontStatus('bold', 700)).toBe('loaded');
|
||||||
|
|
||||||
|
delete (navigator as any).connection;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads variable fonts in data-saver mode regardless of weight', async () => {
|
||||||
|
(navigator as any).connection = { saveData: true };
|
||||||
|
|
||||||
|
manager.touch([makeConfig('vf', { weight: 300, isVariable: true })]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('vf', 300, true)).toBe('loaded');
|
||||||
|
|
||||||
|
delete (navigator as any).connection;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT purge fonts that are still being "touched"', async () => {
|
// ── Phase 1: fetch ────────────────────────────────────────────────────────
|
||||||
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
describe('Phase 1 — fetch', () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
it('sets status to error on fetch failure', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(40000);
|
failManager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
manager.touch([config]);
|
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(20000);
|
it('logs a console error on fetch failure', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
failManager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not set error status or log for aborted fetches', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const abortingCache = {
|
||||||
|
async get(url: string): Promise<never> {
|
||||||
|
throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||||
|
},
|
||||||
|
evict() {},
|
||||||
|
clear() {},
|
||||||
|
};
|
||||||
|
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
|
||||||
|
|
||||||
|
abortManager.touch([makeConfig('aborted')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
// status is left as 'loading' (not 'error') — abort is not a retriable failure
|
||||||
|
expect(abortManager.getFontStatus('aborted', 400)).not.toBe('error');
|
||||||
|
expect(consoleSpy).not.toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serve buffer from memory without calling fetch again', async () => {
|
// ── Phase 2: parse ────────────────────────────────────────────────────────
|
||||||
const config = { id: 'cached', name: 'Cached', url: 'https://example.com/cached.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
describe('Phase 2 — parse', () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
it('sets status to error on parse failure', async () => {
|
||||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const FailingFontFace = vi.fn(function(this: any) {
|
||||||
|
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
|
||||||
|
});
|
||||||
|
vi.stubGlobal('FontFace', FailingFontFace);
|
||||||
|
|
||||||
manager.statuses.delete('cached@400');
|
manager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
manager.touch([config]);
|
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
it('logs a console error on parse failure', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const FailingFontFace = vi.fn(function(this: any) {
|
||||||
|
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
|
||||||
|
});
|
||||||
|
vi.stubGlobal('FontFace', FailingFontFace);
|
||||||
|
|
||||||
|
manager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT purge a pinned font after TTL expires', async () => {
|
// ── #purgeUnused ──────────────────────────────────────────────────────────
|
||||||
const config = { id: 'pinned', name: 'Pinned', url: 'https://example.com/pinned.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
describe('#purgeUnused', () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
it('evicts fonts after TTL expires', async () => {
|
||||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
manager.touch([makeConfig('ephemeral')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
manager.pin('pinned', 400);
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(61000);
|
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
||||||
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
it('removes the evicted key from the eviction policy', async () => {
|
||||||
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
manager.touch([makeConfig('ephemeral')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
|
|
||||||
|
expect(Array.from(eviction.keys())).not.toContain('ephemeral@400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes TTL when font is re-touched before expiry', async () => {
|
||||||
|
const config = makeConfig('active');
|
||||||
|
manager.touch([config]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(40000);
|
||||||
|
manager.touch([config]); // refresh at t≈40s
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(25000); // purge at t≈60s sees only ~20s elapsed → not evicted
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not evict pinned fonts', async () => {
|
||||||
|
manager.touch([makeConfig('pinned')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
manager.pin('pinned', 400);
|
||||||
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
||||||
|
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts font after it is unpinned and TTL expires', async () => {
|
||||||
|
manager.touch([makeConfig('toggled')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
manager.pin('toggled', 400);
|
||||||
|
manager.unpin('toggled', 400);
|
||||||
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
|
|
||||||
|
expect(manager.getFontStatus('toggled', 400)).toBeUndefined();
|
||||||
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should evict a font after it is unpinned and TTL expires', async () => {
|
// ── destroy() ─────────────────────────────────────────────────────────────
|
||||||
const config = { id: 'unpinned', name: 'Unpinned', url: 'https://example.com/unpinned.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
describe('destroy()', () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
it('clears all statuses', async () => {
|
||||||
expect(manager.getFontStatus('unpinned', 400)).toBe('loaded');
|
manager.touch([makeConfig('roboto')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
manager.pin('unpinned', 400);
|
manager.destroy();
|
||||||
manager.unpin('unpinned', 400);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(61000);
|
expect(manager.statuses.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
expect(manager.getFontStatus('unpinned', 400)).toBeUndefined();
|
it('removes all loaded fonts from document.fonts', async () => {
|
||||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
manager.touch([makeConfig('roboto'), makeConfig('inter')]);
|
||||||
});
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
it('should clear pinned set on destroy without errors', async () => {
|
manager.destroy();
|
||||||
const config = {
|
|
||||||
id: 'destroy-pin',
|
|
||||||
name: 'DestroyPin',
|
|
||||||
url: 'https://example.com/destroypin.ttf',
|
|
||||||
weight: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
manager.touch([config]);
|
expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
});
|
||||||
|
|
||||||
manager.pin('destroy-pin', 400);
|
it('prevents further loading after destroy', async () => {
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
|
manager.touch([makeConfig('roboto')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
expect(manager.statuses.size).toBe(0);
|
expect(manager.statuses.size).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,26 +131,31 @@ export class AppliedFontsManager {
|
|||||||
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)
|
this.#scheduleProcessing();
|
||||||
if (typeof requestIdleCallback !== 'undefined') {
|
|
||||||
this.#timeoutId = requestIdleCallback(
|
|
||||||
() => this.#processQueue(),
|
|
||||||
{ timeout: 150 },
|
|
||||||
) as unknown as ReturnType<typeof setTimeout>;
|
|
||||||
this.#pendingType = 'idle';
|
|
||||||
} else {
|
|
||||||
// Fallback to setTimeout with ~60fps timing
|
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
|
||||||
this.#pendingType = 'timeout';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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: 150 },
|
||||||
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
this.#pendingType = 'idle';
|
||||||
|
} else {
|
||||||
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||||
|
this.#pendingType = 'timeout';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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 {
|
||||||
return (navigator as any).connection?.saveData === true;
|
return (navigator as any).connection?.saveData === true;
|
||||||
@@ -186,29 +191,7 @@ export class AppliedFontsManager {
|
|||||||
// ==================== PHASE 1: Concurrent Fetching ====================
|
// ==================== PHASE 1: Concurrent Fetching ====================
|
||||||
// Fetch multiple font files in parallel since network I/O is non-blocking
|
// 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
|
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
|
||||||
const chunk = entries.slice(i, i + concurrency);
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
chunk.map(async ([key, config]) => {
|
|
||||||
this.statuses.set(key, 'loading');
|
|
||||||
// Fetch buffer via cache (checks memory → Cache API → network)
|
|
||||||
const buffer = await this.#cache.get(config.url, this.#abortController.signal);
|
|
||||||
buffers.set(key, buffer);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle fetch errors - set status and increment retry count
|
|
||||||
for (let j = 0; j < results.length; j++) {
|
|
||||||
if (results[j].status === 'rejected') {
|
|
||||||
const [key, config] = chunk[j];
|
|
||||||
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.#queue.incrementRetry(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== PHASE 2: Sequential Parsing ====================
|
// ==================== PHASE 2: Sequential Parsing ====================
|
||||||
@@ -224,19 +207,7 @@ export class AppliedFontsManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await this.#processFont(key, config, buffer);
|
||||||
// Parse buffer into FontFace and register with document
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yield to main thread if needed (prevents UI blocking)
|
// Yield to main thread if needed (prevents UI blocking)
|
||||||
// Chromium: use isInputPending() for optimal responsiveness
|
// Chromium: use isInputPending() for optimal responsiveness
|
||||||
@@ -252,6 +223,62 @@ export class AppliedFontsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** 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();
|
||||||
@@ -276,6 +303,7 @@ export class AppliedFontsManager {
|
|||||||
// Clean up remaining state
|
// Clean up remaining state
|
||||||
this.#loadedFonts.delete(key);
|
this.#loadedFonts.delete(key);
|
||||||
this.statuses.delete(key);
|
this.statuses.delete(key);
|
||||||
|
this.#eviction.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const makeFetcher = (overrides: Partial<Response> = {}) =>
|
|||||||
|
|
||||||
describe('FontBufferCache', () => {
|
describe('FontBufferCache', () => {
|
||||||
let cache: FontBufferCache;
|
let cache: FontBufferCache;
|
||||||
let fetcher: ReturnType<typeof vi.fn>;
|
let fetcher: ReturnType<typeof makeFetcher>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetcher = makeFetcher();
|
fetcher = makeFetcher();
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ describe('FontEvictionPolicy', () => {
|
|||||||
expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf']));
|
expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('remove deletes key from tracking so it no longer appears in keys()', () => {
|
||||||
|
policy.touch('a@400', t0);
|
||||||
|
policy.touch('b@vf', t0);
|
||||||
|
policy.remove('a@400');
|
||||||
|
expect(Array.from(policy.keys())).not.toContain('a@400');
|
||||||
|
expect(Array.from(policy.keys())).toContain('b@vf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove unpins the key so a subsequent touch + TTL would evict it', () => {
|
||||||
|
policy.touch('a@400', t0);
|
||||||
|
policy.pin('a@400');
|
||||||
|
policy.remove('a@400');
|
||||||
|
// re-touch and check it can be evicted again
|
||||||
|
policy.touch('a@400', t0);
|
||||||
|
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('clear resets all state', () => {
|
it('clear resets all state', () => {
|
||||||
policy.touch('a@400', t0);
|
policy.touch('a@400', t0);
|
||||||
policy.pin('a@400');
|
policy.pin('a@400');
|
||||||
|
|||||||
@@ -58,6 +58,12 @@ export class FontEvictionPolicy {
|
|||||||
return this.#usageTracker.keys();
|
return this.#usageTracker.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Removes a font key from tracking. Called by the orchestrator after eviction. */
|
||||||
|
remove(key: string): void {
|
||||||
|
this.#usageTracker.delete(key);
|
||||||
|
this.#pinnedFonts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
/** Clears all usage timestamps and pinned keys. */
|
/** Clears all usage timestamps and pinned keys. */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.#usageTracker.clear();
|
this.#usageTracker.clear();
|
||||||
|
|||||||
Reference in New Issue
Block a user