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:
Ilia Mashkov
2026-05-24 20:00:43 +03:00
parent 07d044f4d6
commit 728380498b
38 changed files with 105 additions and 105 deletions
@@ -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);
});
});
@@ -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();
}
}
@@ -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);
});
});
@@ -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();
}
}
@@ -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);
});
});
@@ -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();
}
}
@@ -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');
});
});
@@ -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}`;
}
@@ -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);
});
});
@@ -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);
}
}
@@ -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();
});
});
@@ -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);
});
}
}