feat: refactor ComparisonStore to use BatchFontStore

Replace hand-rolled async fetching (fetchFontsByIds + isRestoring flag)
with BatchFontStore backed by TanStack Query. Three reactive effects
handle batch sync, CSS font loading, and default-font fallback.
isLoading now derives from batchStore.isLoading + fontsReady.
This commit is contained in:
Ilia Mashkov
2026-04-15 22:25:34 +03:00
parent 10f4781a67
commit adaa6d7648
3 changed files with 159 additions and 590 deletions

View File

@@ -1,5 +1,5 @@
/**
* Font comparison store for side-by-side font comparison
* Font comparison store — TanStack Query refactor
*
* Manages the state for comparing two fonts character by character.
* Persists font selection to localStorage and handles font loading
@@ -7,17 +7,17 @@
*
* Features:
* - Persistent font selection (survives page refresh)
* - Font loading state tracking
* - Font loading state tracking via BatchFontStore + 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,
fetchFontsByIds,
fontStore,
getFontUrl,
} from '$entities/Font';
@@ -47,11 +47,13 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
});
/**
* Store for managing font comparison state
* Store for managing font comparison state.
*
* Handles font selection persistence, fetching, and loading state tracking.
* Uses the CSS Font Loading API to ensure fonts are loaded before
* showing the comparison interface.
* Uses BatchFontStore (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
* storage is empty.
*/
export class ComparisonStore {
/** Font for side A */
@@ -60,8 +62,6 @@ export class ComparisonStore {
#fontB = $state<UnifiedFont | undefined>();
/** Sample text to display */
#sampleText = $state('The quick brown fox jumps over the lazy dog');
/** Whether currently restoring from storage */
#isRestoring = $state(true);
/** Whether fonts are loaded and ready to display */
#fontsReady = $state(false);
/** Active side for single-font operations */
@@ -70,13 +70,32 @@ export class ComparisonStore {
#sliderPosition = $state(50);
/** Typography controls for this comparison */
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
/** TanStack Query-backed batch font fetcher */
#batchStore: BatchFontStore;
constructor() {
this.restoreFromStorage();
// Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value;
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []);
// Reactively handle font loading and default selection
$effect.root(() => {
// Effect 1: Trigger font loading whenever selection or weight changes
// Effect 1: Sync batch results → fontA / fontB
$effect(() => {
const fonts = this.#batchStore.fonts;
if (fonts.length === 0) return;
const { fontAId: aId, fontBId: bId } = storage.value;
if (aId) {
const fa = fonts.find(f => f.id === aId);
if (fa) this.#fontA = fa;
}
if (bId) {
const fb = fonts.find(f => f.id === bId);
if (fb) this.#fontB = fb;
}
});
// Effect 2: Trigger font loading whenever selection or weight changes
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
@@ -104,25 +123,17 @@ export class ComparisonStore {
}
});
// Effect 2: Set defaults if we aren't restoring and have no selection
// Effect 3: Set default fonts when storage is empty
$effect(() => {
// Wait until we are done checking storage
if (this.#isRestoring) {
return;
}
if (this.#fontA && this.#fontB) return;
// If we already have a selection, do nothing
if (this.#fontA && this.#fontB) {
return;
}
// Check if fonts are available to set as defaults
const fonts = fontStore.fonts;
if (fonts.length >= 2) {
// We need full objects with all URLs, so we trigger a batch fetch
// This is the "batch request" seen on initial load when storage is empty
untrack(() => {
this.restoreDefaults([fonts[0].id, fonts[fonts.length - 1].id]);
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#batchStore.setIds([id1, id2]);
});
}
});
@@ -130,26 +141,7 @@ export class ComparisonStore {
}
/**
* Set default fonts by fetching full objects from the API
*/
private async restoreDefaults(ids: string[]) {
this.#isRestoring = true;
try {
const fullFonts = await fetchFontsByIds(ids);
if (fullFonts.length >= 2) {
this.#fontA = fullFonts[0];
this.#fontB = fullFonts[1];
this.updateStorage();
}
} catch (error) {
console.warn('[ComparisonStore] Failed to set defaults:', error);
} finally {
this.#isRestoring = false;
}
}
/**
* Checks if fonts are actually loaded in the browser at current weight
* Checks if fonts are actually loaded in the browser at current weight.
*
* Uses CSS Font Loading API to prevent FOUT. Waits for fonts to load
* and forces a layout/paint cycle before marking as ready.
@@ -182,71 +174,35 @@ export class ComparisonStore {
this.#fontsReady = false;
try {
// Step 1: Load fonts into memory
await Promise.all([
document.fonts.load(fontAString),
document.fonts.load(fontBString),
]);
// Step 2: Wait for browser to be ready to render
await document.fonts.ready;
// Step 3: Force a layout/paint cycle (critical!)
await new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve); // Double rAF ensures paint completes
requestAnimationFrame(resolve);
});
});
this.#fontsReady = true;
} catch (error) {
console.warn('[ComparisonStore] Font loading failed:', error);
setTimeout(() => this.#fontsReady = true, 1000);
setTimeout(() => (this.#fontsReady = true), 1000);
}
}
/**
* Restore state from persistent storage
*
* Fetches saved fonts from the API and restores them to the store.
*/
async restoreFromStorage() {
this.#isRestoring = true;
const { fontAId, fontBId } = storage.value;
if (fontAId && fontBId) {
try {
// Batch fetch the saved fonts
const fonts = await fetchFontsByIds([fontAId, fontBId]);
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
if (loadedFontA && loadedFontB) {
this.#fontA = loadedFontA;
this.#fontB = loadedFontB;
}
} catch (error) {
console.warn('[ComparisonStore] Failed to restore fonts:', error);
}
}
// Mark restoration as complete (whether success or fail)
this.#isRestoring = false;
}
/**
* Update storage with current state
* Updates persistent storage with the current font selection.
*/
private updateStorage() {
// Don't save if we are currently restoring (avoid race)
if (this.#isRestoring) return;
storage.value = {
fontAId: this.#fontA?.id ?? null,
fontBId: this.#fontB?.id ?? null,
};
}
// ── Getters / Setters ─────────────────────────────────────────────────────
/** Typography control manager */
get typography() {
return this.#typography;
@@ -299,33 +255,23 @@ export class ComparisonStore {
this.#sliderPosition = value;
}
/**
* Check if both fonts are selected and loaded
*/
/** Whether both fonts are selected and loaded */
get isReady() {
return !!this.#fontA && !!this.#fontB && this.#fontsReady;
}
/** Whether currently loading or restoring */
/** Whether currently loading (batch fetch in flight or fonts not yet painted) */
get isLoading() {
return this.#isRestoring || !this.#fontsReady;
return this.#batchStore.isLoading || !this.#fontsReady;
}
/**
* Public initializer (optional, as constructor starts it)
*/
initialize() {
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
this.restoreFromStorage();
}
}
/**
* Reset all state and clear storage
* Resets all state, clears storage, and disables the batch query.
*/
resetAll() {
this.#fontA = undefined;
this.#fontB = undefined;
this.#batchStore.setIds([]);
storage.clear();
this.#typography.reset();
}