refactor(Font): rename fontStore and appliedFontsManager
Both names were vague or overloaded: - fontStore / FontStore -> fontCatalogStore / FontCatalogStore Three font-related stores live in this slice; the new name names the paginated catalog specifically. - appliedFontsManager / AppliedFontsManager -> fontLifecycleManager / FontLifecycleManager "Applied" collided with the filter-side appliedFilterStore (different meaning). The class actually orchestrates a load-use-evict lifecycle with FontBufferCache + FontEvictionPolicy + FontLoadQueue collaborators, so "Manager" is justified. Companion types file moved alongside (appliedFonts.ts -> fontLifecycle.ts). Directories, file basenames, factory (createFontStore -> createFontCatalogStore), and the AppliedFontsManagerDeps interface all renamed. All consumers (ComparisonView, SampleList, FontList, FontApplicator, FontVirtualList, FilterAndSortFonts bindings, createFontRowSizeResolver, mocks) updated.
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { FontFetchError } from '../../errors';
|
||||
import { FontBufferCache } from './FontBufferCache';
|
||||
|
||||
const makeBuffer = () => new ArrayBuffer(8);
|
||||
|
||||
const makeFetcher = (overrides: Partial<Response> = {}) =>
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: () => Promise.resolve(makeBuffer()),
|
||||
clone: () => ({ ok: true, status: 200, arrayBuffer: () => Promise.resolve(makeBuffer()) }),
|
||||
...overrides,
|
||||
} as Response);
|
||||
|
||||
describe('FontBufferCache', () => {
|
||||
let cache: FontBufferCache;
|
||||
let fetcher: ReturnType<typeof makeFetcher>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetcher = makeFetcher();
|
||||
cache = new FontBufferCache({ fetcher });
|
||||
});
|
||||
|
||||
it('returns buffer from memory on second call without fetching', async () => {
|
||||
await cache.get('https://example.com/font.woff2');
|
||||
await cache.get('https://example.com/font.woff2');
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('throws FontFetchError on HTTP error with correct status', async () => {
|
||||
const errorFetcher = makeFetcher({ ok: false, status: 404 });
|
||||
const errorCache = new FontBufferCache({ fetcher: errorFetcher });
|
||||
|
||||
const err = await errorCache.get('https://example.com/font.woff2').catch(e => e);
|
||||
expect(err).toBeInstanceOf(FontFetchError);
|
||||
expect(err.status).toBe(404);
|
||||
});
|
||||
|
||||
it('throws FontFetchError on network failure without status', async () => {
|
||||
const networkFetcher = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
const networkCache = new FontBufferCache({ fetcher: networkFetcher });
|
||||
|
||||
const err = await networkCache.get('https://example.com/font.woff2').catch(e => e);
|
||||
expect(err).toBeInstanceOf(FontFetchError);
|
||||
expect(err.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('evict removes url from memory so next call fetches again', async () => {
|
||||
await cache.get('https://example.com/font.woff2');
|
||||
cache.evict('https://example.com/font.woff2');
|
||||
await cache.get('https://example.com/font.woff2');
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clear wipes all memory cache entries', async () => {
|
||||
await cache.get('https://example.com/a.woff2');
|
||||
await cache.get('https://example.com/b.woff2');
|
||||
cache.clear();
|
||||
await cache.get('https://example.com/a.woff2');
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { FontFetchError } from '../../errors';
|
||||
|
||||
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface FontBufferCacheOptions {
|
||||
/**
|
||||
* Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation.
|
||||
*/
|
||||
fetcher?: Fetcher;
|
||||
/**
|
||||
* Cache API cache name. Defaults to `'font-cache-v1'`.
|
||||
*/
|
||||
cacheName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-tier font buffer cache: in-memory → Cache API → network.
|
||||
*
|
||||
* - **Tier 1 (memory):** Fastest — no I/O. Populated after first successful fetch.
|
||||
* - **Tier 2 (Cache API):** Persists across page loads. Silently skipped in private browsing.
|
||||
* - **Tier 3 (network):** Raw fetch. Throws {@link FontFetchError} on failure.
|
||||
*
|
||||
* The `fetcher` option is injectable for testing — pass a `vi.fn()` to avoid real network calls.
|
||||
*/
|
||||
export class FontBufferCache {
|
||||
#buffersByUrl = new Map<string, ArrayBuffer>();
|
||||
|
||||
readonly #fetcher: Fetcher;
|
||||
readonly #cacheName: string;
|
||||
|
||||
constructor(
|
||||
{ fetcher = globalThis.fetch.bind(globalThis), cacheName = 'font-cache-v1' }: FontBufferCacheOptions = {},
|
||||
) {
|
||||
this.#fetcher = fetcher;
|
||||
this.#cacheName = cacheName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the font buffer for the given URL using the three-tier strategy.
|
||||
* Stores the result in memory on success.
|
||||
*
|
||||
* @throws {@link FontFetchError} if the network request fails or returns a non-OK response.
|
||||
*/
|
||||
async get(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||
// Tier 1: in-memory (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.#cacheName);
|
||||
const cached = await cache.match(url);
|
||||
if (cached) {
|
||||
const buffer = await cached.arrayBuffer();
|
||||
this.#buffersByUrl.set(url, buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
||||
}
|
||||
|
||||
// Tier 3: network
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.#fetcher(url, { signal });
|
||||
} catch (cause) {
|
||||
throw new FontFetchError(url, cause);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new FontFetchError(url, undefined, response.status);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#cacheName);
|
||||
await cache.put(url, response.clone());
|
||||
}
|
||||
} catch {
|
||||
// Cache write failed (quota, storage pressure) — return font anyway
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
this.#buffersByUrl.set(url, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a URL from the in-memory cache. Next call to `get()` will re-fetch.
|
||||
*/
|
||||
evict(url: string): void {
|
||||
this.#buffersByUrl.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all in-memory cached buffers.
|
||||
*/
|
||||
clear(): void {
|
||||
this.#buffersByUrl.clear();
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { FontEvictionPolicy } from './FontEvictionPolicy';
|
||||
|
||||
describe('FontEvictionPolicy', () => {
|
||||
let policy: FontEvictionPolicy;
|
||||
const TTL = 1000;
|
||||
const t0 = 100000;
|
||||
|
||||
beforeEach(() => {
|
||||
policy = new FontEvictionPolicy({ ttl: TTL });
|
||||
});
|
||||
|
||||
it('shouldEvict returns false within TTL', () => {
|
||||
policy.touch('a@400', t0);
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL - 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldEvict returns true at TTL boundary', () => {
|
||||
policy.touch('a@400', t0);
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
|
||||
});
|
||||
|
||||
it('shouldEvict returns false for pinned key regardless of TTL', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.pin('a@400');
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldEvict returns true again after unpin past TTL', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.pin('a@400');
|
||||
policy.unpin('a@400');
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
|
||||
});
|
||||
|
||||
it('shouldEvict returns false for untracked key', () => {
|
||||
expect(policy.shouldEvict('never@touched', t0 + TTL * 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('keys returns all tracked keys', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.touch('b@vf', t0);
|
||||
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', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.pin('a@400');
|
||||
policy.clear();
|
||||
expect(Array.from(policy.keys())).toHaveLength(0);
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
interface FontEvictionPolicyOptions {
|
||||
/**
|
||||
* TTL in milliseconds. Defaults to 5 minutes.
|
||||
*/
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks font usage timestamps and pinned keys to determine when a font should be evicted.
|
||||
*
|
||||
* Pure data — no browser APIs. Accepts explicit `now` timestamps so tests
|
||||
* never need fake timers.
|
||||
*/
|
||||
export class FontEvictionPolicy {
|
||||
#usageTracker = new Map<string, number>();
|
||||
#pinnedFonts = new Set<string>();
|
||||
|
||||
readonly #TTL: number;
|
||||
|
||||
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
|
||||
this.#TTL = ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the last-used time for a font key.
|
||||
* @param key - Font key in `{id}@{weight}` or `{id}@vf` format.
|
||||
* @param now - Current timestamp in ms. Defaults to `Date.now()`.
|
||||
*/
|
||||
touch(key: string, now: number = Date.now()): void {
|
||||
this.#usageTracker.set(key, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins a font key so it is never evicted regardless of TTL.
|
||||
*/
|
||||
pin(key: string): void {
|
||||
this.#pinnedFonts.add(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpins a font key, allowing it to be evicted once its TTL expires.
|
||||
*/
|
||||
unpin(key: string): void {
|
||||
this.#pinnedFonts.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the font should be evicted.
|
||||
* A font is evicted when its TTL has elapsed and it is not pinned.
|
||||
* Returns `false` for untracked keys.
|
||||
*
|
||||
* @param key - Font key to check.
|
||||
* @param now - Current timestamp in ms (pass explicitly for deterministic tests).
|
||||
*/
|
||||
shouldEvict(key: string, now: number): boolean {
|
||||
const lastUsed = this.#usageTracker.get(key);
|
||||
if (lastUsed === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (this.#pinnedFonts.has(key)) {
|
||||
return false;
|
||||
}
|
||||
return now - lastUsed >= this.#TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator over all tracked font keys.
|
||||
*/
|
||||
keys(): IterableIterator<string> {
|
||||
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.
|
||||
*/
|
||||
clear(): void {
|
||||
this.#usageTracker.clear();
|
||||
this.#pinnedFonts.clear();
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
import { FontLoadQueue } from './FontLoadQueue';
|
||||
|
||||
const config = (id: string): FontLoadRequestConfig => ({
|
||||
id,
|
||||
name: id,
|
||||
url: `https://example.com/${id}.woff2`,
|
||||
weight: 400,
|
||||
});
|
||||
|
||||
describe('FontLoadQueue', () => {
|
||||
let queue: FontLoadQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
queue = new FontLoadQueue();
|
||||
});
|
||||
|
||||
it('enqueue returns true for a new key', () => {
|
||||
expect(queue.enqueue('a@400', config('a'))).toBe(true);
|
||||
});
|
||||
|
||||
it('enqueue returns false for an already-queued key', () => {
|
||||
queue.enqueue('a@400', config('a'));
|
||||
expect(queue.enqueue('a@400', config('a'))).toBe(false);
|
||||
});
|
||||
|
||||
it('has returns true after enqueue, false after flush', () => {
|
||||
queue.enqueue('a@400', config('a'));
|
||||
expect(queue.has('a@400')).toBe(true);
|
||||
queue.flush();
|
||||
expect(queue.has('a@400')).toBe(false);
|
||||
});
|
||||
|
||||
it('flush returns all entries and atomically clears the queue', () => {
|
||||
queue.enqueue('a@400', config('a'));
|
||||
queue.enqueue('b@700', config('b'));
|
||||
const entries = queue.flush();
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(queue.has('a@400')).toBe(false);
|
||||
expect(queue.has('b@700')).toBe(false);
|
||||
});
|
||||
|
||||
it('isMaxRetriesReached returns false below MAX_RETRIES', () => {
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
expect(queue.isMaxRetriesReached('a@400')).toBe(false);
|
||||
});
|
||||
|
||||
it('isMaxRetriesReached returns true at MAX_RETRIES (3)', () => {
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
expect(queue.isMaxRetriesReached('a@400')).toBe(true);
|
||||
});
|
||||
|
||||
it('clear resets queue and retry counts', () => {
|
||||
queue.enqueue('a@400', config('a'));
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
queue.clear();
|
||||
expect(queue.has('a@400')).toBe(false);
|
||||
expect(queue.isMaxRetriesReached('a@400')).toBe(false);
|
||||
});
|
||||
});
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
|
||||
/**
|
||||
* Manages the font load queue and per-font retry counts.
|
||||
*
|
||||
* Scheduling (when to drain the queue) is handled by the orchestrator —
|
||||
* this class is purely concerned with what is queued and whether retries are exhausted.
|
||||
*/
|
||||
export class FontLoadQueue {
|
||||
#queue = new Map<string, FontLoadRequestConfig>();
|
||||
#retryCounts = new Map<string, number>();
|
||||
|
||||
readonly #MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Adds a font to the queue.
|
||||
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
||||
*/
|
||||
enqueue(key: string, config: FontLoadRequestConfig): boolean {
|
||||
if (this.#queue.has(key)) {
|
||||
return false;
|
||||
}
|
||||
this.#queue.set(key, config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically snapshots and clears the queue.
|
||||
* @returns All queued entries at the time of the call.
|
||||
*/
|
||||
flush(): Array<[string, FontLoadRequestConfig]> {
|
||||
const entries = Array.from(this.#queue.entries());
|
||||
this.#queue.clear();
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the key is currently in the queue.
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
return this.#queue.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments the retry count for a font key.
|
||||
*/
|
||||
incrementRetry(key: string): void {
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the font has reached or exceeded the maximum retry limit.
|
||||
*/
|
||||
isMaxRetriesReached(key: string): boolean {
|
||||
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all queued fonts and resets all retry counts.
|
||||
*/
|
||||
clear(): void {
|
||||
this.#queue.clear();
|
||||
this.#retryCounts.clear();
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { generateFontKey } from './generateFontKey';
|
||||
|
||||
describe('generateFontKey', () => {
|
||||
it('should throw an error if font id is not provided', () => {
|
||||
const config = { weight: 400, isVariable: false };
|
||||
// @ts-expect-error
|
||||
expect(() => generateFontKey(config)).toThrow('Font id is required');
|
||||
});
|
||||
|
||||
it('should generate a font key for a variable font', () => {
|
||||
const config = { id: 'Roboto', weight: 400, isVariable: true };
|
||||
expect(generateFontKey(config)).toBe('roboto@vf');
|
||||
});
|
||||
|
||||
it('should throw an error if font weight is not provided and is not a variable font', () => {
|
||||
const config = { id: 'Roboto', isVariable: false };
|
||||
// @ts-expect-error
|
||||
expect(() => generateFontKey(config)).toThrow('Font weight is required');
|
||||
});
|
||||
|
||||
it('should generate a font key for a non-variable font', () => {
|
||||
const config = { id: 'Roboto', weight: 400, isVariable: false };
|
||||
expect(generateFontKey(config)).toBe('roboto@400');
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
|
||||
export type PartialConfig = Pick<FontLoadRequestConfig, 'id' | 'weight' | 'isVariable'>;
|
||||
|
||||
/**
|
||||
* Generates a font key for a given font load request configuration.
|
||||
* @param config - The font load request configuration.
|
||||
* @returns The generated font key.
|
||||
*/
|
||||
export function generateFontKey(config: PartialConfig): string {
|
||||
if (!config.id) {
|
||||
throw new Error('Font id is required');
|
||||
}
|
||||
if (config.isVariable) {
|
||||
return `${config.id.toLowerCase()}@vf`;
|
||||
}
|
||||
|
||||
if (!config.weight) {
|
||||
throw new Error('Font weight is required');
|
||||
}
|
||||
return `${config.id.toLowerCase()}@${config.weight}`;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import {
|
||||
Concurrency,
|
||||
getEffectiveConcurrency,
|
||||
} from './getEffectiveConcurrency';
|
||||
|
||||
describe('getEffectiveConcurrency', () => {
|
||||
beforeEach(() => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = null;
|
||||
});
|
||||
|
||||
it('should return MAX when connection is not available', () => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = null;
|
||||
expect(getEffectiveConcurrency()).toBe(Concurrency.MAX);
|
||||
});
|
||||
|
||||
it('should return MIN for slow-2g or 2g connection', () => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = { effectiveType: 'slow-2g' };
|
||||
expect(getEffectiveConcurrency()).toBe(Concurrency.MIN);
|
||||
});
|
||||
|
||||
it('should return AVERAGE for 3g connection', () => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = { effectiveType: '3g' };
|
||||
expect(getEffectiveConcurrency()).toBe(Concurrency.AVERAGE);
|
||||
});
|
||||
|
||||
it('should return MAX for other connection types', () => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = { effectiveType: '4g' };
|
||||
expect(getEffectiveConcurrency()).toBe(Concurrency.MAX);
|
||||
});
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
export enum Concurrency {
|
||||
MIN = 1,
|
||||
AVERAGE = 2,
|
||||
MAX = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the amount of fonts for concurrent download based on the user internet connection
|
||||
*/
|
||||
export function getEffectiveConcurrency(): number {
|
||||
const nav = navigator as any;
|
||||
const connection = nav.connection;
|
||||
if (!connection) {
|
||||
return Concurrency.MAX;
|
||||
}
|
||||
|
||||
switch (connection.effectiveType) {
|
||||
case 'slow-2g':
|
||||
case '2g':
|
||||
return Concurrency.MIN;
|
||||
case '3g':
|
||||
return Concurrency.AVERAGE;
|
||||
default:
|
||||
return Concurrency.MAX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { generateFontKey } from './generateFontKey/generateFontKey';
|
||||
export { getEffectiveConcurrency } from './getEffectiveConcurrency/getEffectiveConcurrency';
|
||||
export { loadFont } from './loadFont/loadFont';
|
||||
export { yieldToMainThread } from './yieldToMainThread/yieldToMainThread';
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { FontParseError } from '../../errors';
|
||||
import { loadFont } from './loadFont';
|
||||
|
||||
describe('loadFont', () => {
|
||||
let mockFontInstance: any;
|
||||
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
|
||||
Object.defineProperty(document, 'fonts', { value: mockFontFaceSet, configurable: true, writable: true });
|
||||
|
||||
const MockFontFace = vi.fn(
|
||||
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
|
||||
this.name = name;
|
||||
this.buffer = buffer;
|
||||
this.options = options;
|
||||
this.load = vi.fn().mockResolvedValue(this);
|
||||
mockFontInstance = this;
|
||||
},
|
||||
);
|
||||
vi.stubGlobal('FontFace', MockFontFace);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('constructs FontFace with exact weight for static fonts', async () => {
|
||||
const buffer = new ArrayBuffer(8);
|
||||
await loadFont({ name: 'Roboto', weight: 400 }, buffer);
|
||||
|
||||
expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '400' }));
|
||||
});
|
||||
|
||||
it('constructs FontFace with weight range for variable fonts', async () => {
|
||||
const buffer = new ArrayBuffer(8);
|
||||
await loadFont({ name: 'Roboto', weight: 400, isVariable: true }, buffer);
|
||||
|
||||
expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '100 900' }));
|
||||
});
|
||||
|
||||
it('sets style: normal and display: swap on FontFace options', async () => {
|
||||
await loadFont({ name: 'Lato', weight: 700 }, new ArrayBuffer(8));
|
||||
|
||||
expect(FontFace).toHaveBeenCalledWith(
|
||||
'Lato',
|
||||
expect.anything(),
|
||||
expect.objectContaining({ style: 'normal', display: 'swap' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the buffer as the second argument to FontFace', async () => {
|
||||
const buffer = new ArrayBuffer(16);
|
||||
await loadFont({ name: 'Inter', weight: 400 }, buffer);
|
||||
|
||||
expect(FontFace).toHaveBeenCalledWith('Inter', buffer, expect.anything());
|
||||
});
|
||||
|
||||
it('calls font.load() and adds the font to document.fonts', async () => {
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const result = await loadFont({ name: 'Inter', weight: 400 }, buffer);
|
||||
|
||||
expect(mockFontInstance.load).toHaveBeenCalledOnce();
|
||||
expect(mockFontFaceSet.add).toHaveBeenCalledWith(mockFontInstance);
|
||||
expect(result).toBe(mockFontInstance);
|
||||
});
|
||||
|
||||
it('throws FontParseError when font.load() rejects', async () => {
|
||||
const loadError = new Error('parse failed');
|
||||
const MockFontFace = vi.fn(
|
||||
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
|
||||
this.load = vi.fn().mockRejectedValue(loadError);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal('FontFace', MockFontFace);
|
||||
|
||||
await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf(
|
||||
FontParseError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws FontParseError when document.fonts.add throws', async () => {
|
||||
const addError = new Error('add failed');
|
||||
mockFontFaceSet.add.mockImplementation(() => {
|
||||
throw addError;
|
||||
});
|
||||
|
||||
await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf(
|
||||
FontParseError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
import { FontParseError } from '../../errors';
|
||||
|
||||
export type PartialConfig = Pick<FontLoadRequestConfig, 'weight' | 'name' | 'isVariable'>;
|
||||
/**
|
||||
* Loads a font from a buffer and adds it to the document's font collection.
|
||||
* @param config - The font load request configuration.
|
||||
* @param buffer - The buffer containing the font data.
|
||||
* @returns A promise that resolves to the loaded `FontFace`.
|
||||
* @throws {@link FontParseError} When the font buffer cannot be parsed or added to the document font set.
|
||||
*/
|
||||
export async function loadFont(config: PartialConfig, buffer: BufferSource): Promise<FontFace> {
|
||||
try {
|
||||
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||
const font = new FontFace(config.name, buffer, {
|
||||
weight: weightRange,
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
});
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
|
||||
return font;
|
||||
} catch (error) {
|
||||
throw new FontParseError(config.name, error);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { yieldToMainThread } from './yieldToMainThread';
|
||||
|
||||
describe('yieldToMainThread', () => {
|
||||
it('uses scheduler.yield when available', async () => {
|
||||
const mockYield = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('scheduler', { yield: mockYield });
|
||||
|
||||
await yieldToMainThread();
|
||||
|
||||
expect(mockYield).toHaveBeenCalledOnce();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it('falls back to MessageChannel when scheduler is unavailable', async () => {
|
||||
// scheduler is not defined in jsdom by default
|
||||
await expect(yieldToMainThread()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
||||
*/
|
||||
export async function yieldToMainThread(): Promise<void> {
|
||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||
await scheduler.yield();
|
||||
} else {
|
||||
await new Promise<void>(resolve => {
|
||||
const ch = new MessageChannel();
|
||||
ch.port1.onmessage = () => resolve();
|
||||
ch.port2.postMessage(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user