chore(appliedFontsStore): move generateFontKey into separate function and cover it with tests

This commit is contained in:
Ilia Mashkov
2026-04-03 12:50:50 +03:00
parent 05e4c082ed
commit a711e4e12a
4 changed files with 98 additions and 33 deletions

View File

@@ -4,6 +4,7 @@ import {
type FontLoadStatus,
} from '../../types';
import {
generateFontKey,
getEffectiveConcurrency,
loadFont,
yieldToMainThread,
@@ -81,11 +82,6 @@ export class AppliedFontsManager {
}
}
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
#getFontKey(id: string, weight: number, isVariable: boolean): string {
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
}
/**
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
*
@@ -93,34 +89,40 @@ export class AppliedFontsManager {
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
*/
touch(configs: FontLoadRequestConfig[]) {
if (this.#abortController.signal.aborted) return;
const now = Date.now();
let hasNewItems = false;
for (const config of configs) {
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
this.#usageTracker.set(key, now);
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);
hasNewItems = true;
if (this.#abortController.signal.aborted) {
return;
}
if (hasNewItems && !this.#timeoutId) {
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';
try {
const now = Date.now();
let hasNewItems = false;
for (const config of configs) {
const key = generateFontKey(config);
this.#usageTracker.set(key, now);
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);
hasNewItems = true;
}
if (hasNewItems && !this.#timeoutId) {
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';
}
}
} catch (error) {
console.error(error);
}
}
@@ -274,17 +276,32 @@ export class AppliedFontsManager {
/** Returns current loading status for a font, or undefined if never requested. */
getFontStatus(id: string, weight: number, isVariable = false) {
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
try {
const key = generateFontKey({ id, weight, isVariable });
return this.statuses.get(key);
} catch (error) {
console.error(error);
}
}
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
pin(id: string, weight: number, isVariable?: boolean): void {
this.#pinnedFonts.add(this.#getFontKey(id, weight, !!isVariable));
try {
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. */
unpin(id: string, weight: number, isVariable?: boolean): void {
this.#pinnedFonts.delete(this.#getFontKey(id, weight, !!isVariable));
try {
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. */

View File

@@ -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');
});
});

View File

@@ -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}`;
}

View File

@@ -1,3 +1,4 @@
export { generateFontKey } from './generateFontKey/generateFontKey';
export { getEffectiveConcurrency } from './getEffectiveConcurrency/getEffectiveConcurrency';
export { loadFont } from './loadFont/loadFont';
export { yieldToMainThread } from './yieldToMainThread/yieldToMainThread';