Merge pull request 'refactor/code-splitting' (#31) from refactor/code-splitting into main
Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>glyphdiff</title>
|
<title>glyphdiff</title>
|
||||||
|
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ export {
|
|||||||
UNIFIED_FONTS,
|
UNIFIED_FONTS,
|
||||||
} from './lib/mocks';
|
} from './lib/mocks';
|
||||||
|
|
||||||
|
export {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from './lib/errors/errors';
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
|
|||||||
51
src/entities/Font/lib/errors/errors.test.ts
Normal file
51
src/entities/Font/lib/errors/errors.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from './errors';
|
||||||
|
|
||||||
|
describe('FontNetworkError', () => {
|
||||||
|
it('has correct name', () => {
|
||||||
|
const err = new FontNetworkError();
|
||||||
|
expect(err.name).toBe('FontNetworkError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is instance of Error', () => {
|
||||||
|
expect(new FontNetworkError()).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores cause', () => {
|
||||||
|
const cause = new Error('network down');
|
||||||
|
const err = new FontNetworkError(cause);
|
||||||
|
expect(err.cause).toBe(cause);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has default message', () => {
|
||||||
|
expect(new FontNetworkError().message).toBe('Failed to fetch fonts from proxy API');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FontResponseError', () => {
|
||||||
|
it('has correct name', () => {
|
||||||
|
const err = new FontResponseError('response', undefined);
|
||||||
|
expect(err.name).toBe('FontResponseError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is instance of Error', () => {
|
||||||
|
expect(new FontResponseError('response.fonts', null)).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores field', () => {
|
||||||
|
const err = new FontResponseError('response.fonts', 42);
|
||||||
|
expect(err.field).toBe('response.fonts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores received value', () => {
|
||||||
|
const err = new FontResponseError('response.fonts', 42);
|
||||||
|
expect(err.received).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('message includes field name', () => {
|
||||||
|
const err = new FontResponseError('response.fonts', null);
|
||||||
|
expect(err.message).toContain('response.fonts');
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/entities/Font/lib/errors/errors.ts
Normal file
28
src/entities/Font/lib/errors/errors.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Thrown when the network request to the proxy API fails.
|
||||||
|
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
|
||||||
|
*/
|
||||||
|
export class FontNetworkError extends Error {
|
||||||
|
readonly name = 'FontNetworkError';
|
||||||
|
|
||||||
|
constructor(public readonly cause?: unknown) {
|
||||||
|
super('Failed to fetch fonts from proxy API');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the proxy API returns a response with an unexpected shape.
|
||||||
|
*
|
||||||
|
* @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`).
|
||||||
|
* @property received - The actual value received at that field, for debugging.
|
||||||
|
*/
|
||||||
|
export class FontResponseError extends Error {
|
||||||
|
readonly name = 'FontResponseError';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly field: string,
|
||||||
|
public readonly received: unknown,
|
||||||
|
) {
|
||||||
|
super(`Invalid proxy API response: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,3 +56,8 @@ export {
|
|||||||
type MockUnifiedFontOptions,
|
type MockUnifiedFontOptions,
|
||||||
UNIFIED_FONTS,
|
UNIFIED_FONTS,
|
||||||
} from './mocks';
|
} from './mocks';
|
||||||
|
|
||||||
|
export {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from './errors/errors';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type {
|
|||||||
FontFeatures,
|
FontFeatures,
|
||||||
FontFiles,
|
FontFiles,
|
||||||
FontItem,
|
FontItem,
|
||||||
|
FontLoadRequestConfig,
|
||||||
|
FontLoadStatus,
|
||||||
FontMetadata,
|
FontMetadata,
|
||||||
FontProvider,
|
FontProvider,
|
||||||
// Fontshare API types
|
// Fontshare API types
|
||||||
@@ -37,7 +39,6 @@ export type {
|
|||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createUnifiedFontStore,
|
createUnifiedFontStore,
|
||||||
type FontConfigRequest,
|
|
||||||
type UnifiedFontStore,
|
type UnifiedFontStore,
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|||||||
@@ -1,73 +1,63 @@
|
|||||||
/** @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 './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
|
||||||
|
// ── Fake collaborators ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class FakeBufferCache {
|
||||||
|
async get(_url: string): Promise<ArrayBuffer> {
|
||||||
|
return new ArrayBuffer(8);
|
||||||
|
}
|
||||||
|
evict(_url: string): 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 mockFetch: any;
|
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||||
let failUrls: Set<string>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
failUrls = new Set();
|
eviction = new FontEvictionPolicy({ ttl: 60000 });
|
||||||
|
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
|
||||||
|
|
||||||
mockFontFaceSet = {
|
|
||||||
add: 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) {
|
|
||||||
this.name = name;
|
|
||||||
this.bufferOrUrl = bufferOrUrl;
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
||||||
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);
|
||||||
|
|
||||||
// 3. Mock fetch to return fake ArrayBuffer data
|
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
||||||
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(() => {
|
||||||
@@ -76,138 +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);
|
||||||
|
|
||||||
// Advance to trigger the 16ms debounced #processQueue
|
expect(manager.getFontStatus('roboto', 400)).toBe('loaded');
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
});
|
||||||
|
|
||||||
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 handle font loading errors gracefully', async () => {
|
// ── queue processing ──────────────────────────────────────────────────────
|
||||||
// Suppress expected console error for clean test logs
|
|
||||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const failUrl = 'https://example.com/fail.ttf';
|
describe('queue processing', () => {
|
||||||
failUrls.add(failUrl);
|
it('filters non-critical weights in data-saver mode', async () => {
|
||||||
|
(navigator as any).connection = { saveData: true };
|
||||||
|
|
||||||
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
|
manager.touch([
|
||||||
|
makeConfig('light', { weight: 300 }),
|
||||||
|
makeConfig('regular', { weight: 400 }),
|
||||||
|
makeConfig('bold', { weight: 700 }),
|
||||||
|
]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
manager.touch([config]);
|
expect(manager.getFontStatus('light', 300)).toBeUndefined();
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
expect(manager.getFontStatus('regular', 400)).toBe('loaded');
|
||||||
|
expect(manager.getFontStatus('bold', 700)).toBe('loaded');
|
||||||
|
|
||||||
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
delete (navigator as any).connection;
|
||||||
spy.mockRestore();
|
});
|
||||||
|
|
||||||
|
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 purge fonts after TTL expires', async () => {
|
// ── Phase 1: fetch ────────────────────────────────────────────────────────
|
||||||
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
describe('Phase 1 — fetch', () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
it('sets status to error on fetch failure', async () => {
|
||||||
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||||
|
|
||||||
// Move clock forward past TTL (5m) and Purge Interval (1m)
|
failManager.touch([makeConfig('broken')]);
|
||||||
// advanceTimersByTimeAsync is key here; it handles the promises inside the interval
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
|
||||||
|
|
||||||
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
||||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
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 NOT purge fonts that are still being "touched"', async () => {
|
// ── Phase 2: parse ────────────────────────────────────────────────────────
|
||||||
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
describe('Phase 2 — parse', () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
it('sets status to 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);
|
||||||
|
|
||||||
// Advance 4 minutes
|
manager.touch([makeConfig('broken')]);
|
||||||
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
// Refresh touch
|
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
||||||
manager.touch([config]);
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
// Advance another 2 minutes (Total 6 since start)
|
it('logs a console error on parse failure', async () => {
|
||||||
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
|
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);
|
||||||
|
|
||||||
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
manager.touch([makeConfig('broken')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalled();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serve buffer from memory without calling fetch again', async () => {
|
// ── #purgeUnused ──────────────────────────────────────────────────────────
|
||||||
const config = { id: 'cached', name: 'Cached', url: 'https://example.com/cached.ttf', weight: 400 };
|
|
||||||
|
|
||||||
// First load — populates in-memory buffer
|
describe('#purgeUnused', () => {
|
||||||
manager.touch([config]);
|
it('evicts fonts after TTL expires', async () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
manager.touch([makeConfig('ephemeral')]);
|
||||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Simulate eviction by deleting the status entry directly
|
await vi.advanceTimersByTimeAsync(61000);
|
||||||
manager.statuses.delete('cached@400');
|
|
||||||
|
|
||||||
// Second load — should hit in-memory buffer, not network
|
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
||||||
manager.touch([config]);
|
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
});
|
||||||
|
|
||||||
expect(manager.getFontStatus('cached', 400)).toBe('loaded');
|
it('removes the evicted key from the eviction policy', async () => {
|
||||||
// fetch should still only have been called once (buffer was reused)
|
manager.touch([makeConfig('ephemeral')]);
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
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 NOT purge a pinned font after TTL expires', async () => {
|
// ── destroy() ─────────────────────────────────────────────────────────────
|
||||||
const config = { id: 'pinned', name: 'Pinned', url: 'https://example.com/pinned.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
describe('destroy()', () => {
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
it('clears all statuses', async () => {
|
||||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
manager.touch([makeConfig('roboto')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
manager.pin('pinned', 400);
|
manager.destroy();
|
||||||
|
|
||||||
// Advance past TTL + purge interval
|
expect(manager.statuses.size).toBe(0);
|
||||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
});
|
||||||
|
|
||||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
it('removes all loaded fonts from document.fonts', async () => {
|
||||||
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
manager.touch([makeConfig('roboto'), makeConfig('inter')]);
|
||||||
});
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
it('should evict a font after it is unpinned and TTL expires', async () => {
|
manager.destroy();
|
||||||
const config = { id: 'unpinned', name: 'Unpinned', url: 'https://example.com/unpinned.ttf', weight: 400 };
|
|
||||||
|
|
||||||
manager.touch([config]);
|
expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2);
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
});
|
||||||
expect(manager.getFontStatus('unpinned', 400)).toBe('loaded');
|
|
||||||
|
|
||||||
manager.pin('unpinned', 400);
|
it('prevents further loading after destroy', async () => {
|
||||||
manager.unpin('unpinned', 400);
|
manager.destroy();
|
||||||
|
manager.touch([makeConfig('roboto')]);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
|
||||||
// Advance past TTL + purge interval
|
expect(manager.statuses.size).toBe(0);
|
||||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
});
|
||||||
|
|
||||||
expect(manager.getFontStatus('unpinned', 400)).toBeUndefined();
|
|
||||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear pinned set on destroy without errors', async () => {
|
|
||||||
const config = {
|
|
||||||
id: 'destroy-pin',
|
|
||||||
name: 'DestroyPin',
|
|
||||||
url: 'https://example.com/destroypin.ttf',
|
|
||||||
weight: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
manager.touch([config]);
|
|
||||||
await vi.advanceTimersByTimeAsync(50);
|
|
||||||
|
|
||||||
manager.pin('destroy-pin', 400);
|
|
||||||
manager.destroy();
|
|
||||||
|
|
||||||
expect(manager.statuses.size).toBe(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,30 +1,26 @@
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import {
|
||||||
|
type FontLoadRequestConfig,
|
||||||
|
type FontLoadStatus,
|
||||||
|
} from '../../types';
|
||||||
|
import {
|
||||||
|
FontFetchError,
|
||||||
|
FontParseError,
|
||||||
|
} from './errors';
|
||||||
|
import {
|
||||||
|
generateFontKey,
|
||||||
|
getEffectiveConcurrency,
|
||||||
|
loadFont,
|
||||||
|
yieldToMainThread,
|
||||||
|
} from './utils';
|
||||||
|
import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
|
||||||
|
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||||
|
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
||||||
|
|
||||||
/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */
|
interface AppliedFontsManagerDeps {
|
||||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
cache?: FontBufferCache;
|
||||||
|
eviction?: FontEvictionPolicy;
|
||||||
/** Configuration for a font load request. */
|
queue?: FontLoadQueue;
|
||||||
export interface FontConfigRequest {
|
|
||||||
/**
|
|
||||||
* Unique identifier for the font (e.g., "lato", "roboto").
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* URL pointing to the font file (typically .ttf or .woff2).
|
|
||||||
*/
|
|
||||||
url: string;
|
|
||||||
/**
|
|
||||||
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
|
||||||
*/
|
|
||||||
weight: number;
|
|
||||||
/**
|
|
||||||
* Variable fonts load once per ID; static fonts load per weight.
|
|
||||||
*/
|
|
||||||
isVariable?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,14 +47,16 @@ export interface FontConfigRequest {
|
|||||||
* **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, FontConfigRequest>();
|
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -72,112 +70,95 @@ 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, FontStatus>();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
|
||||||
*
|
*
|
||||||
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
|
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
|
||||||
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
|
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
|
||||||
*/
|
*/
|
||||||
touch(configs: FontConfigRequest[]) {
|
touch(configs: FontLoadRequestConfig[]) {
|
||||||
if (this.#abortController.signal.aborted) return;
|
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;
|
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const now = Date.now();
|
||||||
|
let hasNewItems = false;
|
||||||
|
|
||||||
if (hasNewItems && !this.#timeoutId) {
|
for (const config of configs) {
|
||||||
if (typeof requestIdleCallback !== 'undefined') {
|
const key = generateFontKey(config);
|
||||||
this.#timeoutId = requestIdleCallback(
|
|
||||||
() => this.#processQueue(),
|
// Update last-used timestamp for LRU eviction policy
|
||||||
{ timeout: 150 },
|
this.#eviction.touch(key, now);
|
||||||
) as unknown as ReturnType<typeof setTimeout>;
|
|
||||||
this.#pendingType = 'idle';
|
const status = this.statuses.get(key);
|
||||||
} else {
|
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
// Skip fonts that are already loaded or currently loading
|
||||||
this.#pendingType = 'timeout';
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasNewItems && !this.#timeoutId) {
|
||||||
|
this.#scheduleProcessing();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
|
/**
|
||||||
async #yieldToMain(): Promise<void> {
|
* Schedules `#processQueue()` via `requestIdleCallback` (150ms timeout) when available,
|
||||||
// @ts-expect-error - scheduler not in TypeScript lib yet
|
* falling back to `setTimeout(16ms)` for ~60fps timing.
|
||||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
*/
|
||||||
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
#scheduleProcessing(): void {
|
||||||
await scheduler.yield();
|
if (typeof requestIdleCallback !== 'undefined') {
|
||||||
|
this.#timeoutId = requestIdleCallback(
|
||||||
|
() => this.#processQueue(),
|
||||||
|
{ timeout: 150 },
|
||||||
|
) as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
this.#pendingType = 'idle';
|
||||||
} else {
|
} else {
|
||||||
await new Promise<void>(resolve => {
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||||
const ch = new MessageChannel();
|
this.#pendingType = 'timeout';
|
||||||
ch.port1.onmessage = () => resolve();
|
|
||||||
ch.port2.postMessage(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
|
|
||||||
#getEffectiveConcurrency(): number {
|
|
||||||
const nav = navigator as any;
|
|
||||||
const conn = nav.connection;
|
|
||||||
if (!conn) return 4;
|
|
||||||
|
|
||||||
switch (conn.effectiveType) {
|
|
||||||
case 'slow-2g':
|
|
||||||
case '2g':
|
|
||||||
return 1;
|
|
||||||
case '3g':
|
|
||||||
return 2;
|
|
||||||
default:
|
|
||||||
return 4;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,173 +169,179 @@ 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 = this.#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) {
|
||||||
const chunk = entries.slice(i, i + concurrency);
|
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
|
||||||
const results = await Promise.allSettled(
|
|
||||||
chunk.map(async ([key, config]) => {
|
|
||||||
this.statuses.set(key, 'loading');
|
|
||||||
const buffer = await this.#fetchFontBuffer(
|
|
||||||
config.url,
|
|
||||||
this.#abortController.signal,
|
|
||||||
);
|
|
||||||
buffers.set(key, buffer);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let j = 0; j < results.length; j++) {
|
|
||||||
if (results[j].status === 'rejected') {
|
|
||||||
const [key, config] = chunk[j];
|
|
||||||
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
|
|
||||||
this.statuses.set(key, 'error');
|
|
||||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
||||||
if (!buffer) continue;
|
// Skip fonts that failed to fetch in phase 1
|
||||||
|
if (!buffer) {
|
||||||
try {
|
continue;
|
||||||
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);
|
|
||||||
this.#loadedFonts.set(key, font);
|
|
||||||
this.#buffersByUrl.set(config.url, buffer);
|
|
||||||
this.#urlByKey.set(key, config.url);
|
|
||||||
this.statuses.set(key, 'loaded');
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.name === 'AbortError') continue;
|
|
||||||
console.error(`Font parse failed: ${config.name}`, e);
|
|
||||||
this.statuses.set(key, 'error');
|
|
||||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.#processFont(key, config, buffer);
|
||||||
|
|
||||||
|
// 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 this.#yieldToMain();
|
await yieldToMainThread();
|
||||||
lastYield = performance.now();
|
lastYield = performance.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches font with three-tier lookup: in-memory buffer → Cache API → network.
|
* Fetches a chunk of fonts concurrently and populates `buffers` with successful results.
|
||||||
* Cache failures (private browsing, quota limits) are silently ignored.
|
* 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 #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
async #fetchChunk(
|
||||||
// Tier 1: in-memory buffer (fastest, no I/O)
|
chunk: Array<[string, FontLoadRequestConfig]>,
|
||||||
const inMemory = this.#buffersByUrl.get(url);
|
buffers: Map<string, ArrayBuffer>,
|
||||||
if (inMemory) return inMemory;
|
): 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 };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Tier 2: Cache API
|
for (const result of results) {
|
||||||
try {
|
if (result.ok) continue;
|
||||||
if (typeof caches !== 'undefined') {
|
const { key, config, reason } = result;
|
||||||
const cache = await caches.open(this.#CACHE_NAME);
|
const isAbort = reason instanceof FontFetchError
|
||||||
const cached = await cache.match(url);
|
&& reason.cause instanceof Error
|
||||||
if (cached) return cached.arrayBuffer();
|
&& reason.cause.name === 'AbortError';
|
||||||
|
if (isAbort) continue;
|
||||||
|
if (reason instanceof FontFetchError) {
|
||||||
|
console.error(`Font fetch failed: ${config.name}`, reason);
|
||||||
}
|
}
|
||||||
} catch {
|
this.statuses.set(key, 'error');
|
||||||
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
this.#queue.incrementRetry(key);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tier 3: network
|
/**
|
||||||
const response = await fetch(url, { signal });
|
* Parses a fetched buffer into a {@link FontFace}, registers it with `document.fonts`,
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
* 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 {
|
try {
|
||||||
if (typeof caches !== 'undefined') {
|
const font = await loadFont(config, buffer);
|
||||||
const cache = await caches.open(this.#CACHE_NAME);
|
this.#loadedFonts.set(key, font);
|
||||||
await cache.put(url, response.clone());
|
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);
|
||||||
}
|
}
|
||||||
} 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);
|
this.#eviction.remove(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) {
|
||||||
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
try {
|
||||||
|
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
|
||||||
|
} catch (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 {
|
||||||
this.#pinnedFonts.add(this.#getFontKey(id, weight, !!isVariable));
|
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
||||||
this.#pinnedFonts.delete(this.#getFontKey(id, weight, !!isVariable));
|
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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);
|
||||||
@@ -365,25 +352,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
src/entities/Font/model/store/appliedFontsStore/errors.ts
Normal file
35
src/entities/Font/model/store/appliedFontsStore/errors.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Thrown by {@link FontBufferCache} when a font file cannot be retrieved from the network or cache.
|
||||||
|
*
|
||||||
|
* @property url - The URL that was requested.
|
||||||
|
* @property cause - The underlying error, if any.
|
||||||
|
* @property status - HTTP status code. Present on HTTP errors, absent on network failures.
|
||||||
|
*/
|
||||||
|
export class FontFetchError extends Error {
|
||||||
|
readonly name = 'FontFetchError';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly url: string,
|
||||||
|
public readonly cause?: unknown,
|
||||||
|
public readonly status?: number,
|
||||||
|
) {
|
||||||
|
super(status ? `HTTP ${status} fetching font: ${url}` : `Network error fetching font: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by {@link loadFont} when a font buffer cannot be parsed into a {@link FontFace}.
|
||||||
|
*
|
||||||
|
* @property fontName - The display name of the font that failed to parse.
|
||||||
|
* @property cause - The underlying error from the FontFace API.
|
||||||
|
*/
|
||||||
|
export class FontParseError extends Error {
|
||||||
|
readonly name = 'FontParseError';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly fontName: string,
|
||||||
|
public readonly cause?: unknown,
|
||||||
|
) {
|
||||||
|
super(`Failed to parse font: ${fontName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/** @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,97 @@
|
|||||||
|
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,76 @@
|
|||||||
|
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,57 @@
|
|||||||
|
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,93 @@
|
|||||||
|
/** @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,16 @@
|
|||||||
|
/**
|
||||||
|
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
||||||
|
*/
|
||||||
|
export async function yieldToMainThread(): Promise<void> {
|
||||||
|
// @ts-expect-error - scheduler not in TypeScript lib yet
|
||||||
|
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||||
|
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
||||||
|
await scheduler.yield();
|
||||||
|
} else {
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
const ch = new MessageChannel();
|
||||||
|
ch.port1.onmessage = () => resolve();
|
||||||
|
ch.port2.postMessage(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
import { QueryClient } from '@tanstack/query-core';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { generateMockFonts } from '../../../lib/mocks/fonts.mock';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
import { BaseFontStore } from './baseFontStore.svelte';
|
||||||
|
|
||||||
|
vi.mock('$shared/api/queryClient', () => ({
|
||||||
|
queryClient: new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
|
||||||
|
interface TestParams {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
q?: string;
|
||||||
|
providers?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
subsets?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestFontStore extends BaseFontStore<TestParams> {
|
||||||
|
protected getQueryKey(params: TestParams) {
|
||||||
|
return ['testFonts', params] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async fetchFn(params: TestParams): Promise<UnifiedFont[]> {
|
||||||
|
return generateMockFonts(params.limit || 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('baseFontStore', () => {
|
||||||
|
describe('constructor', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new store with initial params', () => {
|
||||||
|
const store = new TestFontStore({ limit: 20, offset: 10 });
|
||||||
|
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
expect(store.params.offset).toBe(10);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults offset to 0 if not provided', () => {
|
||||||
|
const store = new TestFontStore({ limit: 10 });
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes observer with query options', () => {
|
||||||
|
const store = new TestFontStore({ limit: 10 });
|
||||||
|
|
||||||
|
expect((store as any).observer).toBeDefined();
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('params getter', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10, offset: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns merged internal params', () => {
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults offset to 0 when undefined', () => {
|
||||||
|
const store2 = new TestFontStore({});
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store2.params.offset).toBe(0);
|
||||||
|
store2.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('state getters', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fonts', () => {
|
||||||
|
it('returns fonts after auto-fetch on mount', async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fonts when data is loaded', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fonts when data is loaded', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLoading', () => {
|
||||||
|
it('is false after initial fetch completes', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when error occurs', async () => {
|
||||||
|
vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(new Error('fail'));
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isFetching', () => {
|
||||||
|
it('is false after fetch completes', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.isFetching).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true during refetch', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const refetchPromise = store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.isFetching).toBe(true);
|
||||||
|
await refetchPromise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isError', () => {
|
||||||
|
it('is false initially', () => {
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true after fetch error', async () => {
|
||||||
|
vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(new Error('fail'));
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false after successful fetch', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.isError).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error', () => {
|
||||||
|
it('is null initially', () => {
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error object after fetch error', async () => {
|
||||||
|
const testError = new Error('test error');
|
||||||
|
vi.spyOn(store, 'fetchFn' as any).mockRejectedValue(testError);
|
||||||
|
await store.refetch().catch(() => {});
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.error).toBe(testError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null after successful fetch', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.error).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isEmpty', () => {
|
||||||
|
it('is true when no fonts loaded and not loading', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
store.setQueryData(() => []);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when fonts are present', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when loading', () => {
|
||||||
|
expect(store.isEmpty).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setParams', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10, offset: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges new params with existing', () => {
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces existing param values', () => {
|
||||||
|
store.setParams({ limit: 30 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
store.setParams({ limit: 40 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.limit).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers observer options update', async () => {
|
||||||
|
const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions');
|
||||||
|
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(setOptionsSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateInternalParams', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10, offset: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates internal params without triggering setParams hooks', () => {
|
||||||
|
(store as any).updateInternalParams({ offset: 0 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
expect(store.params.limit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges with existing internal params', () => {
|
||||||
|
(store as any).updateInternalParams({ offset: 0, limit: 30 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
expect(store.params.limit).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates observer options', () => {
|
||||||
|
const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions');
|
||||||
|
|
||||||
|
(store as any).updateInternalParams({ offset: 0 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(setOptionsSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalidate', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates query for current params', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
store.invalidate();
|
||||||
|
|
||||||
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ['testFonts', store.params],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers refetch of invalidated query', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const fetchSpy = vi.spyOn(store, 'fetchFn' as any);
|
||||||
|
store.invalidate();
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('calls cleanup function', () => {
|
||||||
|
const store = new TestFontStore({ limit: 10 });
|
||||||
|
const cleanupSpy = vi.spyOn(store, 'cleanup' as any);
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
|
||||||
|
expect(cleanupSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be called multiple times without error', () => {
|
||||||
|
const store = new TestFontStore({ limit: 10 });
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
store.destroy();
|
||||||
|
store.destroy();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refetch', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a refetch', async () => {
|
||||||
|
const fetchSpy = vi.spyOn(store, 'fetchFn' as any);
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates observer options before refetching', async () => {
|
||||||
|
const setOptionsSpy = vi.spyOn((store as any).observer, 'setOptions');
|
||||||
|
const refetchSpy = vi.spyOn((store as any).observer, 'refetch');
|
||||||
|
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(setOptionsSpy).toHaveBeenCalledBefore(refetchSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses current params for refetch', async () => {
|
||||||
|
store.setParams({ limit: 20 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.limit).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prefetch', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefetches data with provided params', async () => {
|
||||||
|
const prefetchSpy = vi.spyOn(queryClient, 'prefetchQuery');
|
||||||
|
|
||||||
|
await store.prefetch({ limit: 20, offset: 0 });
|
||||||
|
|
||||||
|
expect(prefetchSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores prefetched data in cache', async () => {
|
||||||
|
queryClient.clear();
|
||||||
|
|
||||||
|
const store2 = new TestFontStore({ limit: 10 });
|
||||||
|
await store2.prefetch({ limit: 5, offset: 0 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const cached = store2.getCachedData();
|
||||||
|
expect(cached).toBeDefined();
|
||||||
|
expect(cached?.length).toBeGreaterThanOrEqual(0);
|
||||||
|
store2.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancel', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels ongoing queries', () => {
|
||||||
|
const cancelSpy = vi.spyOn(queryClient, 'cancelQueries');
|
||||||
|
|
||||||
|
store.cancel();
|
||||||
|
|
||||||
|
expect(cancelSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ['testFonts', store.params],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCachedData', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when no data cached', () => {
|
||||||
|
queryClient.clear();
|
||||||
|
|
||||||
|
const store2 = new TestFontStore({ limit: 10 });
|
||||||
|
expect(store2.getCachedData()).toBeUndefined();
|
||||||
|
store2.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached data after fetch', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const cached = store.getCachedData();
|
||||||
|
expect(cached).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns data from manual cache update', () => {
|
||||||
|
store.setQueryData(() => [generateMockFonts(1)[0]]);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const cached = store.getCachedData();
|
||||||
|
expect(cached).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setQueryData', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets data in cache', () => {
|
||||||
|
store.setQueryData(() => [generateMockFonts(1)[0]]);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const cached = store.getCachedData();
|
||||||
|
expect(cached).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing cached data', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
store.setQueryData(old => [...(old || []), generateMockFonts(1)[0]]);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const cached = store.getCachedData();
|
||||||
|
expect(cached).toHaveLength(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('receives previous data in updater function', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const updater = vi.fn((old: UnifiedFont[] | undefined) => old || []);
|
||||||
|
store.setQueryData(updater);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(updater).toHaveBeenCalledWith(expect.any(Array));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOptions', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns query options with query key', () => {
|
||||||
|
const options = (store as any).getOptions();
|
||||||
|
|
||||||
|
expect(options.queryKey).toEqual(['testFonts', store.params]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns query options with query fn', () => {
|
||||||
|
const options = (store as any).getOptions();
|
||||||
|
|
||||||
|
expect(options.queryFn).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses provided params when passed', () => {
|
||||||
|
const customParams = { limit: 20, offset: 0 };
|
||||||
|
const options = (store as any).getOptions(customParams);
|
||||||
|
|
||||||
|
expect(options.queryKey).toEqual(['testFonts', customParams]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has default staleTime and gcTime', () => {
|
||||||
|
const options = (store as any).getOptions();
|
||||||
|
|
||||||
|
expect(options.staleTime).toBe(5 * 60 * 1000);
|
||||||
|
expect(options.gcTime).toBe(10 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('observer integration', () => {
|
||||||
|
let store: TestFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new TestFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs observer state to Svelte state', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('observer syncs on state changes', async () => {
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect((store as any).result.data).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('effect cleanup', () => {
|
||||||
|
it('cleanup function is set on constructor', () => {
|
||||||
|
const store = new TestFontStore({ limit: 10 });
|
||||||
|
|
||||||
|
expect(store.cleanup).toBeDefined();
|
||||||
|
expect(typeof store.cleanup).toBe('function');
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type QueryObserverOptions,
|
type QueryObserverOptions,
|
||||||
type QueryObserverResult,
|
type QueryObserverResult,
|
||||||
} from '@tanstack/query-core';
|
} from '@tanstack/query-core';
|
||||||
import type { UnifiedFont } from '../types';
|
import type { UnifiedFont } from '../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for font stores using TanStack Query
|
* Base class for font stores using TanStack Query
|
||||||
@@ -23,25 +23,22 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
*/
|
*/
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
|
|
||||||
/** Reactive parameter bindings from external sources */
|
|
||||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
|
||||||
/** Internal parameter state */
|
/** Internal parameter state */
|
||||||
#internalParams = $state<TParams>({} as TParams);
|
#internalParams = $state<TParams>({} as TParams);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merged params from internal state and all bindings
|
* Merged params from internal state
|
||||||
* Automatically updates when bindings or internal params change
|
* Computed synchronously on access
|
||||||
*/
|
*/
|
||||||
params = $derived.by(() => {
|
get params(): TParams {
|
||||||
let merged = { ...this.#internalParams };
|
// Default offset to 0 if undefined (for pagination methods)
|
||||||
|
let result = this.#internalParams as TParams;
|
||||||
// Merge all binding results into params
|
if (result.offset === undefined) {
|
||||||
for (const getter of this.#bindings) {
|
result = { ...result, offset: 0 } as TParams;
|
||||||
const bindingResult = getter();
|
|
||||||
merged = { ...merged, ...bindingResult };
|
|
||||||
}
|
}
|
||||||
return merged as TParams;
|
|
||||||
});
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/** TanStack Query result state */
|
/** TanStack Query result state */
|
||||||
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
||||||
@@ -89,9 +86,10 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
* @param params - Query parameters (defaults to current params)
|
* @param params - Query parameters (defaults to current params)
|
||||||
*/
|
*/
|
||||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||||
|
// Always use current params, not the captured closure params
|
||||||
return {
|
return {
|
||||||
queryKey: this.getQueryKey(params),
|
queryKey: this.getQueryKey(params),
|
||||||
queryFn: () => this.fetchFn(params),
|
queryFn: () => this.fetchFn(this.params),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
gcTime: 10 * 60 * 1000,
|
gcTime: 10 * 60 * 1000,
|
||||||
};
|
};
|
||||||
@@ -117,30 +115,35 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
return this.result.isError;
|
return this.result.isError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The error from the last failed fetch, or null if no error. */
|
||||||
|
get error(): Error | null {
|
||||||
|
return this.result.error ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether no fonts are loaded (not loading and empty array) */
|
/** Whether no fonts are loaded (not loading and empty array) */
|
||||||
get isEmpty() {
|
get isEmpty() {
|
||||||
return !this.isLoading && this.fonts.length === 0;
|
return !this.isLoading && this.fonts.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a reactive parameter binding
|
|
||||||
* @param getter - Function that returns partial params to merge
|
|
||||||
* @returns Unbind function to remove the binding
|
|
||||||
*/
|
|
||||||
addBinding(getter: () => Partial<TParams>) {
|
|
||||||
this.#bindings.push(getter);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this.#bindings = this.#bindings.filter(b => b !== getter);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update query parameters
|
* Update query parameters
|
||||||
* @param newParams - Partial params to merge with existing
|
* @param newParams - Partial params to merge with existing
|
||||||
*/
|
*/
|
||||||
setParams(newParams: Partial<TParams>) {
|
setParams(newParams: Partial<TParams>) {
|
||||||
this.#internalParams = { ...this.params, ...newParams };
|
this.#internalParams = { ...this.#internalParams, ...newParams };
|
||||||
|
// Manually update observer options since effects may not run in test contexts
|
||||||
|
this.observer.setOptions(this.getOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update internal params without triggering setParams hooks
|
||||||
|
* Used for resetting offset when filters change
|
||||||
|
* @param newParams - Partial params to merge with existing
|
||||||
|
*/
|
||||||
|
protected updateInternalParams(newParams: Partial<TParams>) {
|
||||||
|
this.#internalParams = { ...this.#internalParams, ...newParams };
|
||||||
|
// Update observer options
|
||||||
|
this.observer.setOptions(this.getOptions());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,6 +164,8 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
* Manually trigger a refetch
|
* Manually trigger a refetch
|
||||||
*/
|
*/
|
||||||
async refetch() {
|
async refetch() {
|
||||||
|
// Update options before refetching to ensure current params are used
|
||||||
|
this.observer.setOptions(this.getOptions());
|
||||||
await this.observer.refetch();
|
await this.observer.refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,15 +185,6 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear cache for current params
|
|
||||||
*/
|
|
||||||
clearCache() {
|
|
||||||
this.qc.removeQueries({
|
|
||||||
queryKey: this.getQueryKey(this.params),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached data without triggering fetch
|
* Get cached data without triggering fetch
|
||||||
*/
|
*/
|
||||||
@@ -11,10 +11,7 @@ export {
|
|||||||
createUnifiedFontStore,
|
createUnifiedFontStore,
|
||||||
type UnifiedFontStore,
|
type UnifiedFontStore,
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
} from './unifiedFontStore.svelte';
|
} from './unifiedFontStore/unifiedFontStore.svelte';
|
||||||
|
|
||||||
// Applied fonts manager (CSS loading - unchanged)
|
// Applied fonts manager (CSS loading - unchanged)
|
||||||
export {
|
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
appliedFontsManager,
|
|
||||||
type FontConfigRequest,
|
|
||||||
} from './appliedFontsStore/appliedFontsStore.svelte';
|
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import { QueryClient } from '@tanstack/query-core';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
|
|
||||||
|
vi.mock('$shared/api/queryClient', () => ({
|
||||||
|
queryClient: new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 0,
|
||||||
|
gcTime: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
fetchProxyFonts: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
import { fetchProxyFonts } from '../../../api';
|
||||||
|
import {
|
||||||
|
generateMixedCategoryFonts,
|
||||||
|
generateMockFonts,
|
||||||
|
} from '../../../lib/mocks/fonts.mock';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
import { UnifiedFontStore } from './unifiedFontStore.svelte';
|
||||||
|
|
||||||
|
const mockedFetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const makeResponse = (
|
||||||
|
fonts: UnifiedFont[],
|
||||||
|
meta: { total?: number; limit?: number; offset?: number } = {},
|
||||||
|
) => ({
|
||||||
|
fonts,
|
||||||
|
total: meta.total ?? fonts.length,
|
||||||
|
limit: meta.limit ?? 10,
|
||||||
|
offset: meta.offset ?? 0,
|
||||||
|
});
|
||||||
|
describe('unifiedFontStore', () => {
|
||||||
|
describe('fetchFn — error paths', () => {
|
||||||
|
let store: UnifiedFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets isError and error getter when fetchProxyFonts throws', async () => {
|
||||||
|
mockedFetch.mockRejectedValue(new Error('network down'));
|
||||||
|
await store.refetch().catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
|
expect((store.error as FontNetworkError).cause).toBeInstanceOf(Error);
|
||||||
|
expect(store.isError).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FontResponseError when response is falsy', async () => {
|
||||||
|
mockedFetch.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await store.refetch().catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FontResponseError when response.fonts is missing', async () => {
|
||||||
|
mockedFetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
|
||||||
|
|
||||||
|
await store.refetch().catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response.fonts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws FontResponseError when response.fonts is not an array', async () => {
|
||||||
|
mockedFetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
|
||||||
|
|
||||||
|
await store.refetch().catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
|
expect((store.error as FontResponseError).field).toBe('response.fonts');
|
||||||
|
expect((store.error as FontResponseError).received).toBe('bad');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchFn — success path', () => {
|
||||||
|
let store: UnifiedFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates fonts after a successful fetch', async () => {
|
||||||
|
const fonts = generateMockFonts(3);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(fonts));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(3);
|
||||||
|
expect(store.fonts[0].id).toBe(fonts[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores pagination metadata from response', async () => {
|
||||||
|
const fonts = generateMockFonts(3);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: 30, limit: 10, offset: 0 }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.pagination.total).toBe(30);
|
||||||
|
expect(store.pagination.limit).toBe(10);
|
||||||
|
expect(store.pagination.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces accumulated fonts on offset-0 fetch', async () => {
|
||||||
|
const first = generateMockFonts(3);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(first));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const second = generateMockFonts(2);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(second));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(2);
|
||||||
|
expect(store.fonts[0].id).toBe(second[0].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends fonts when fetching at offset > 0', async () => {
|
||||||
|
const firstPage = generateMockFonts(3);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(firstPage, { total: 6, limit: 3, offset: 0 }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
const secondPage = generateMockFonts(3).map((f, i) => ({
|
||||||
|
...f,
|
||||||
|
id: `page2-font-${i + 1}`,
|
||||||
|
}));
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(secondPage, { total: 6, limit: 3, offset: 3 }));
|
||||||
|
store.setParams({ offset: 3 });
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(6);
|
||||||
|
expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(firstPage.map(f => f.id));
|
||||||
|
expect(store.fonts.slice(3).map(f => f.id)).toEqual(secondPage.map(f => f.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pagination state', () => {
|
||||||
|
let store: UnifiedFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns default pagination before any fetch', () => {
|
||||||
|
expect(store.pagination.total).toBe(0);
|
||||||
|
expect(store.pagination.hasMore).toBe(false);
|
||||||
|
expect(store.pagination.page).toBe(1);
|
||||||
|
expect(store.pagination.totalPages).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes hasMore as true when more pages remain', async () => {
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.pagination.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes hasMore as false on last page', async () => {
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 20, limit: 10, offset: 10 }));
|
||||||
|
store.setParams({ offset: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.pagination.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes page and totalPages from response metadata', async () => {
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||||
|
store.setParams({ offset: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.pagination.page).toBe(2);
|
||||||
|
expect(store.pagination.totalPages).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pagination navigation', () => {
|
||||||
|
let store: UnifiedFontStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||||
|
store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
await tick();
|
||||||
|
await store.refetch();
|
||||||
|
await tick();
|
||||||
|
flushSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nextPage() advances offset by limit when hasMore', () => {
|
||||||
|
store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nextPage() does nothing when hasMore is false', async () => {
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 20, limit: 10, offset: 10 }));
|
||||||
|
store.setParams({ offset: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
store.nextPage();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevPage() decrements offset by limit when on page > 1', async () => {
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||||
|
store.setParams({ offset: 10 });
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
store.prevPage();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevPage() does nothing on the first page', () => {
|
||||||
|
store.prevPage();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage() sets the correct offset', () => {
|
||||||
|
store.goToPage(2);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage() does nothing for page 0', () => {
|
||||||
|
store.goToPage(0);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goToPage() does nothing for page beyond totalPages', () => {
|
||||||
|
store.goToPage(99);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setLimit() updates the limit param', () => {
|
||||||
|
store.setLimit(25);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.limit).toBe(25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filter setters', () => {
|
||||||
|
let store: UnifiedFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setProviders() updates the providers param', () => {
|
||||||
|
store.setProviders(['google']);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.providers).toEqual(['google']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setCategories() updates the categories param', () => {
|
||||||
|
store.setCategories(['serif']);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.categories).toEqual(['serif']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSubsets() updates the subsets param', () => {
|
||||||
|
store.setSubsets(['cyrillic']);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.subsets).toEqual(['cyrillic']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch() sets the q param', () => {
|
||||||
|
store.setSearch('roboto');
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.q).toBe('roboto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSearch() with empty string sets q to undefined', () => {
|
||||||
|
store.setSearch('roboto');
|
||||||
|
store.setSearch('');
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.q).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setSort() updates the sort param', () => {
|
||||||
|
store.setSort('popularity');
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.sort).toBe('popularity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filter change resets pagination', () => {
|
||||||
|
let store: UnifiedFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
flushSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets offset to 0 when a filter changes', () => {
|
||||||
|
store.setParams({ offset: 20 });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
store.setParams({ q: 'roboto' });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.params.offset).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears accumulated fonts when a filter changes', async () => {
|
||||||
|
const fonts = generateMockFonts(3);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(fonts));
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
expect(store.fonts).toHaveLength(3);
|
||||||
|
|
||||||
|
store.setParams({ q: 'roboto' });
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('category getters', () => {
|
||||||
|
let store: UnifiedFontStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.destroy();
|
||||||
|
queryClient.clear();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sansSerifFonts returns only sans-serif fonts', async () => {
|
||||||
|
const fonts = generateMixedCategoryFonts(2);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
expect(store.sansSerifFonts).toHaveLength(2);
|
||||||
|
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serifFonts returns only serif fonts', async () => {
|
||||||
|
const fonts = generateMixedCategoryFonts(2);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.serifFonts).toHaveLength(2);
|
||||||
|
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displayFonts returns only display fonts', async () => {
|
||||||
|
const fonts = generateMixedCategoryFonts(2);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.displayFonts).toHaveLength(2);
|
||||||
|
expect(store.displayFonts.every(f => f.category === 'display')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handwritingFonts returns only handwriting fonts', async () => {
|
||||||
|
const fonts = generateMixedCategoryFonts(2);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.handwritingFonts).toHaveLength(2);
|
||||||
|
expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('monospaceFonts returns only monospace fonts', async () => {
|
||||||
|
const fonts = generateMixedCategoryFonts(2);
|
||||||
|
mockedFetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||||
|
await store.refetch();
|
||||||
|
|
||||||
|
expect(store.monospaceFonts).toHaveLength(2);
|
||||||
|
expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('destroy', () => {
|
||||||
|
it('calls parent destroy and filterCleanup', () => {
|
||||||
|
const store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
const parentDestroySpy = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(store)), 'destroy');
|
||||||
|
|
||||||
|
store.destroy();
|
||||||
|
|
||||||
|
expect(parentDestroySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be called multiple times without throwing', () => {
|
||||||
|
const store = new UnifiedFontStore({ limit: 10 });
|
||||||
|
store.destroy();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
store.destroy();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,10 +13,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { QueryObserverOptions } from '@tanstack/query-core';
|
import type { QueryObserverOptions } from '@tanstack/query-core';
|
||||||
import type { ProxyFontsParams } from '../../api';
|
import type { ProxyFontsParams } from '../../../api';
|
||||||
import { fetchProxyFonts } from '../../api';
|
import { fetchProxyFonts } from '../../../api';
|
||||||
import type { UnifiedFont } from '../types';
|
import {
|
||||||
import { BaseFontStore } from './baseFontStore.svelte';
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '../../../lib/errors/errors';
|
||||||
|
import type { UnifiedFont } from '../../types';
|
||||||
|
import { BaseFontStore } from '../baseFontStore/baseFontStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
||||||
@@ -24,7 +28,7 @@ import { BaseFontStore } from './baseFontStore.svelte';
|
|||||||
* Extends BaseFontStore to provide:
|
* Extends BaseFontStore to provide:
|
||||||
* - Reactive state management
|
* - Reactive state management
|
||||||
* - TanStack Query integration for caching
|
* - TanStack Query integration for caching
|
||||||
* - Dynamic parameter binding for filters
|
* - Filter change tracking with pagination reset
|
||||||
* - Pagination support
|
* - Pagination support
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
@@ -93,7 +97,7 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
/**
|
/**
|
||||||
* Track previous filter params to detect changes and reset pagination
|
* Track previous filter params to detect changes and reset pagination
|
||||||
*/
|
*/
|
||||||
#previousFilterParams = $state<string>('');
|
#previousFilterParams = $state<string | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleanup function for the filter tracking effect
|
* Cleanup function for the filter tracking effect
|
||||||
@@ -130,11 +134,12 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
// Effect: Sync state from Query result (Handles Cache Hits)
|
// Effect: Sync state from Query result (Handles Cache Hits)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const data = this.result.data;
|
const data = this.result.data;
|
||||||
const offset = this.params.offset || 0;
|
const offset = this.params.offset ?? 0;
|
||||||
|
|
||||||
// When we have data and we are at the start (offset 0),
|
// When we have data and we are at the start (offset 0),
|
||||||
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
||||||
// This fixes the issue where cache hits skip fetchFn side-effects.
|
// This fixes the issue where cache hits skip fetchFn side-effects.
|
||||||
|
// Only sync at offset 0 to avoid clearing fonts during cache hits at other offsets.
|
||||||
if (offset === 0 && data && data.length > 0) {
|
if (offset === 0 && data && data.length > 0) {
|
||||||
this.#accumulatedFonts = data;
|
this.#accumulatedFonts = data;
|
||||||
}
|
}
|
||||||
@@ -188,37 +193,35 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
* Returns the full response including pagination metadata
|
* Returns the full response including pagination metadata
|
||||||
*/
|
*/
|
||||||
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
||||||
const response = await fetchProxyFonts(params);
|
let response: Awaited<ReturnType<typeof fetchProxyFonts>>;
|
||||||
|
try {
|
||||||
|
response = await fetchProxyFonts(params);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new FontNetworkError(cause);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate response structure
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
|
throw new FontResponseError('response', response);
|
||||||
throw new Error('Proxy API returned undefined response');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.fonts) {
|
if (!response.fonts) {
|
||||||
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
|
throw new FontResponseError('response.fonts', response.fonts);
|
||||||
throw new Error('Proxy API response missing fonts array');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(response.fonts)) {
|
if (!Array.isArray(response.fonts)) {
|
||||||
console.error('[UnifiedFontStore] response.fonts is not an array', {
|
throw new FontResponseError('response.fonts', response.fonts);
|
||||||
fonts: response.fonts,
|
|
||||||
});
|
|
||||||
throw new Error('Proxy API fonts is not an array');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store pagination metadata separately for derived values
|
|
||||||
this.#paginationMetadata = {
|
this.#paginationMetadata = {
|
||||||
total: response.total ?? 0,
|
total: response.total ?? 0,
|
||||||
limit: response.limit ?? this.params.limit ?? 50,
|
limit: response.limit ?? this.params.limit ?? 50,
|
||||||
offset: response.offset ?? this.params.offset ?? 0,
|
offset: response.offset ?? this.params.offset ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Accumulate fonts for infinite scroll
|
const offset = params.offset ?? 0;
|
||||||
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
if (offset === 0) {
|
||||||
// This prevents race conditions and double-setting.
|
// Replace accumulated fonts on offset-0 fetch
|
||||||
if (params.offset !== 0) {
|
this.#accumulatedFonts = response.fonts;
|
||||||
|
} else {
|
||||||
|
// Append fonts when fetching at offset > 0
|
||||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +263,57 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
return !this.isLoading && this.fonts.length === 0;
|
return !this.isLoading && this.fonts.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if filter params changed and reset if needed
|
||||||
|
* Manually called in setParams to handle test contexts where $effect doesn't run
|
||||||
|
*/
|
||||||
|
#checkAndResetFilters(newParams: Partial<ProxyFontsParams>) {
|
||||||
|
// Only check filter-related params (not offset/limit/page)
|
||||||
|
const isFilterChange = 'q' in newParams || 'providers' in newParams || 'categories' in newParams
|
||||||
|
|| 'subsets' in newParams;
|
||||||
|
|
||||||
|
if (!isFilterChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterParams = JSON.stringify({
|
||||||
|
providers: this.params.providers,
|
||||||
|
categories: this.params.categories,
|
||||||
|
subsets: this.params.subsets,
|
||||||
|
q: this.params.q,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filterParams !== this.#previousFilterParams) {
|
||||||
|
// Reset offset if filter params changed
|
||||||
|
if (this.params.offset !== 0) {
|
||||||
|
// Update internal params directly to avoid recursion
|
||||||
|
this.updateInternalParams({ offset: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear fonts if there are accumulated fonts
|
||||||
|
// (to avoid clearing on initial setup when no fonts exist)
|
||||||
|
if (this.#accumulatedFonts.length > 0) {
|
||||||
|
this.#accumulatedFonts = [];
|
||||||
|
// Clear the result to prevent effect from using stale cached data
|
||||||
|
this.result.data = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.invalidate();
|
||||||
|
this.#previousFilterParams = filterParams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override setParams to check for filter changes
|
||||||
|
* @param newParams - Partial params to merge with existing
|
||||||
|
*/
|
||||||
|
setParams(newParams: Partial<ProxyFontsParams>) {
|
||||||
|
// First update params normally
|
||||||
|
super.setParams(newParams);
|
||||||
|
// Then check if filters changed (for test contexts)
|
||||||
|
this.#checkAndResetFilters(newParams);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set providers filter
|
* Set providers filter
|
||||||
*/
|
*/
|
||||||
@@ -56,3 +56,5 @@ export type {
|
|||||||
FontCollectionSort,
|
FontCollectionSort,
|
||||||
FontCollectionState,
|
FontCollectionState,
|
||||||
} from './store';
|
} from './store';
|
||||||
|
|
||||||
|
export * from './store/appliedFonts';
|
||||||
|
|||||||
30
src/entities/Font/model/types/store/appliedFonts.ts
Normal file
30
src/entities/Font/model/types/store/appliedFonts.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Configuration for a font load request.
|
||||||
|
*/
|
||||||
|
export interface FontLoadRequestConfig {
|
||||||
|
/**
|
||||||
|
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* URL pointing to the font file (typically .ttf or .woff2).
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||||
|
*/
|
||||||
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Variable fonts load once per ID; static fonts load per weight.
|
||||||
|
*/
|
||||||
|
isVariable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading state of a font.
|
||||||
|
*/
|
||||||
|
export type FontLoadStatus = 'loading' | 'loaded' | 'error';
|
||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { getFontUrl } from '../../lib';
|
import { getFontUrl } from '../../lib';
|
||||||
import {
|
import {
|
||||||
type FontConfigRequest,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
@@ -54,7 +54,7 @@ const isLoading = $derived(
|
|||||||
);
|
);
|
||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||||
const configs: FontConfigRequest[] = [];
|
const configs: FontLoadRequestConfig[] = [];
|
||||||
|
|
||||||
visibleItems.forEach(item => {
|
visibleItems.forEach(item => {
|
||||||
const url = getFontUrl(item, weight);
|
const url = getFontUrl(item, weight);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
|
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
|
||||||
* // Returns: "?category=serif&subsets=latin%2Clatin-ext"
|
* // Returns: "?category=serif&subsets=latin&subsets=latin-ext"
|
||||||
*
|
*
|
||||||
* buildQueryString({ limit: 50, page: 1 })
|
* buildQueryString({ limit: 50, page: 1 })
|
||||||
* // Returns: "?limit=50&page=1"
|
* // Returns: "?limit=50&page=1"
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
* // Returns: ""
|
* // Returns: ""
|
||||||
*
|
*
|
||||||
* buildQueryString({ search: 'hello world', active: true })
|
* buildQueryString({ search: 'hello world', active: true })
|
||||||
* // Returns: "?search=hello%20world&active=true"
|
* // Returns: "?search=hello+world&active=true"
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export type QueryParams = Record<string, QueryParamValue | undefined | null>;
|
|||||||
*
|
*
|
||||||
* Handles:
|
* Handles:
|
||||||
* - Primitive values (string, number, boolean) - converted to strings
|
* - Primitive values (string, number, boolean) - converted to strings
|
||||||
* - Arrays - comma-separated values
|
* - Arrays - multiple parameters with same key (e.g., ?key=1&key=2&key=3)
|
||||||
* - null/undefined - omitted from output
|
* - null/undefined - omitted from output
|
||||||
* - Special characters - URL encoded
|
* - Special characters - URL encoded
|
||||||
*
|
*
|
||||||
@@ -51,14 +51,12 @@ export function buildQueryString(params: QueryParams): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle arrays (comma-separated values)
|
// Handle arrays - append each item as separate parameter with same key
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const joined = value
|
for (const item of value) {
|
||||||
.filter(item => item !== undefined && item !== null)
|
if (item !== undefined && item !== null) {
|
||||||
.map(String)
|
searchParams.append(key, String(item));
|
||||||
.join(',');
|
}
|
||||||
if (joined) {
|
|
||||||
searchParams.append(key, joined);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle primitives
|
// Handle primitives
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setupFiles: ['./vitest.setup.unit.ts'],
|
setupFiles: ['./vitest.setup.unit.ts'],
|
||||||
globals: false,
|
globals: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user