refactor: extract BatchFontStore into new FetchFontsByIds feature

The byId font fetch was a verb-oriented capability with a single
consumer driven by a feature need (materializing comparison picks).
That shape belongs at the feature layer, not on the entity.

Move:
- entities/Font/model/store/batchFontStore -> features/FetchFontsByIds/model/store/fontsByIdsStore
- Class BatchFontStore -> FontsByIdsStore

entities/Font retains the transport primitives (fetchFontsByIds,
seedFontCache) and the keyspace (fontKeys); the feature wraps them in
the reactive store. comparisonStore now imports FontsByIdsStore from
the new feature. The proxy API is imported via direct path so vi.spyOn
on the source module still observes the call.
This commit is contained in:
Ilia Mashkov
2026-05-24 19:41:40 +03:00
parent df59dfda02
commit 07d044f4d6
8 changed files with 37 additions and 42 deletions
+1
View File
@@ -9,6 +9,7 @@ export {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxy/proxyFonts';
export type {
ProxyFontsParams,
-3
View File
@@ -1,9 +1,6 @@
// Applied fonts manager
export * from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore/batchFontStore.svelte';
// Single FontStore
export {
createFontStore,
+1
View File
@@ -0,0 +1 @@
export { FontsByIdsStore } from './model';
@@ -0,0 +1 @@
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
@@ -1,14 +1,14 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
import {
fetchFontsByIds,
seedFontCache,
} from '../../../api/proxy/proxyFonts';
} from '$entities/Font/api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import type { UnifiedFont } from '../../types';
} from '$entities/Font/lib/errors/errors';
import type { UnifiedFont } from '$entities/Font/model/types';
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
/**
* Internal fetcher that seeds the cache and handles error wrapping.
@@ -35,11 +35,10 @@ async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
}
/**
* Reactive store for fetching and caching batches of fonts by ID.
* Integrates with TanStack Query via BaseQueryStore and handles
* normalized cache seeding.
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
*/
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
constructor(initialIds: string[] = []) {
super({
queryKey: fontKeys.batch(initialIds),
@@ -1,3 +1,8 @@
import * as api from '$entities/Font/api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '$entities/Font/lib/errors/errors';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
@@ -7,14 +12,9 @@ import {
it,
vi,
} from 'vitest';
import * as api from '../../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../../lib/errors/errors';
import { BatchFontStore } from './batchFontStore.svelte';
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
describe('BatchFontStore', () => {
describe('FontsByIdsStore', () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
@@ -23,7 +23,7 @@ describe('BatchFontStore', () => {
describe('Fetch Behavior', () => {
it('should skip fetch when initialized with empty IDs', async () => {
const spy = vi.spyOn(api, 'fetchFontsByIds');
const store = new BatchFontStore([]);
const store = new FontsByIdsStore([]);
expect(spy).not.toHaveBeenCalled();
expect(store.fonts).toEqual([]);
});
@@ -31,7 +31,7 @@ describe('BatchFontStore', () => {
it('should fetch and seed cache for valid IDs', async () => {
const fonts = [{ id: 'a', name: 'A' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
});
@@ -42,7 +42,7 @@ describe('BatchFontStore', () => {
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
});
@@ -51,7 +51,7 @@ describe('BatchFontStore', () => {
describe('Error Handling', () => {
it('should wrap network failures in FontNetworkError', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontNetworkError);
});
@@ -59,7 +59,7 @@ describe('BatchFontStore', () => {
it('should handle malformed API responses with FontResponseError', async () => {
// Mocking a malformed response that the store should validate
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontResponseError);
});
@@ -67,7 +67,7 @@ describe('BatchFontStore', () => {
it('should have null error in success state', async () => {
const fonts = [{ id: 'a' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(store.error).toBeNull();
});
@@ -78,7 +78,7 @@ describe('BatchFontStore', () => {
const fonts1 = [{ id: 'a' }] as any[];
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
spy.mockClear();
@@ -97,7 +97,7 @@ describe('BatchFontStore', () => {
.mockResolvedValueOnce(fonts1)
.mockResolvedValueOnce(fonts2);
const store = new BatchFontStore(['a']);
const store = new FontsByIdsStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
store.setIds(['b']);
@@ -7,14 +7,13 @@
*
* Features:
* - Persistent font selection (survives page refresh)
* - Font loading state tracking via BatchFontStore + TanStack Query
* - Font loading state tracking via FontsByIdsStore + TanStack Query
* - Sample text management
* - Typography controls (size, weight, line height, spacing)
* - Slider position for character-by-character morphing
*/
import {
BatchFontStore,
type FontLoadRequestConfig,
type UnifiedFont,
appliedFontsManager,
@@ -22,6 +21,7 @@ import {
getFontUrl,
} from '$entities/Font';
import { typographySettingsStore } from '$features/AdjustTypography/model';
import { FontsByIdsStore } from '$features/FetchFontsByIds';
import { createPersistentStore } from '$shared/lib';
import { untrack } from 'svelte';
import { getPretextFontString } from '../../lib';
@@ -51,7 +51,7 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
/**
* Store for managing font comparison state.
*
* Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing
* Uses FontsByIdsStore (TanStack Query) to fetch fonts by ID, replacing
* the previous hand-rolled async fetch approach. Three reactive effects
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the
* CSS Font Loading API, and (3) falling back to default fonts when
@@ -85,17 +85,17 @@ export class ComparisonStore {
/**
* TanStack Query-backed store for efficient batch font retrieval
*/
#batchStore: BatchFontStore;
#fontsByIdsStore: FontsByIdsStore;
constructor() {
// Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value;
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []);
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
$effect.root(() => {
// Effect 1: Sync batch results → fontA / fontB
$effect(() => {
const fonts = this.#batchStore.fonts;
const fonts = this.#fontsByIdsStore.fonts;
if (fonts.length === 0) {
return;
}
@@ -157,7 +157,7 @@ export class ComparisonStore {
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#batchStore.setIds([id1, id2]);
this.#fontsByIdsStore.setIds([id1, id2]);
});
}
});
@@ -316,7 +316,7 @@ export class ComparisonStore {
* True if any font is currently being fetched or loaded (reactive)
*/
get isLoading() {
return this.#batchStore.isLoading || !this.#fontsReady;
return this.#fontsByIdsStore.isLoading || !this.#fontsReady;
}
/**
@@ -325,7 +325,7 @@ export class ComparisonStore {
resetAll() {
this.#fontA = undefined;
this.#fontB = undefined;
this.#batchStore.setIds([]);
this.#fontsByIdsStore.setIds([]);
storage.clear();
typographySettingsStore.reset();
}
@@ -1,7 +1,7 @@
/**
* Unit tests for ComparisonStore (TanStack Query refactor)
*
* Uses the real BatchFontStore so Svelte $state reactivity works correctly.
* Uses the real FontsByIdsStore so Svelte $state reactivity works correctly.
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
*/
@@ -53,12 +53,8 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>();
const { BatchFontStore } = await import(
'$entities/Font/model/store/batchFontStore/batchFontStore.svelte'
);
return {
...actual,
BatchFontStore,
fontStore: { fonts: [] },
appliedFontsManager: {
touch: vi.fn(),
@@ -129,7 +125,7 @@ describe('ComparisonStore', () => {
});
});
describe('Restoration from Storage (via BatchFontStore)', () => {
describe('Restoration from Storage (via FontsByIdsStore)', () => {
it('should restore fontA and fontB from stored IDs', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;