refactor(font): replace fontLifecycleManager singleton with lazy accessor

Convert the eager fontLifecycleManager singleton to getFontLifecycleManager()
(+ __resetFontLifecycleManager for tests), so its AbortController/FontFace
bookkeeping is set up on first use rather than at module load. Update consumers
(FontVirtualList, FontList, SampleList) and resolve it once as a field in
comparisonStore; the comparisonStore mock now exposes getFontLifecycleManager.
This commit is contained in:
Ilia Mashkov
2026-06-01 18:49:47 +03:00
parent 839460726e
commit b3bc40b76c
6 changed files with 43 additions and 26 deletions
@@ -419,7 +419,18 @@ export class FontLifecycleManager {
} }
} }
let _fontLifecycleManager: FontLifecycleManager | undefined;
/** /**
* Singleton instance — use throughout the application for unified font loading state. * App-wide font lifecycle manager, created on first access. Lazy so its
* AbortController / FontFace bookkeeping isn't set up at module load.
*/ */
export const fontLifecycleManager = new FontLifecycleManager(); export function getFontLifecycleManager(): FontLifecycleManager {
return (_fontLifecycleManager ??= new FontLifecycleManager());
}
// test-only reset, so specs don't share loaded-font/eviction state
export function __resetFontLifecycleManager() {
_fontLifecycleManager?.destroy();
_fontLifecycleManager = undefined;
}
@@ -15,8 +15,8 @@ import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestCon
import { import {
type FontLoadRequestConfig, type FontLoadRequestConfig,
type UnifiedFont, type UnifiedFont,
fontLifecycleManager,
getFontCatalog, getFontCatalog,
getFontLifecycleManager,
} from '../../model'; } from '../../model';
interface Props extends interface Props extends
@@ -53,6 +53,7 @@ let {
}: Props = $props(); }: Props = $props();
const fontCatalog = getFontCatalog(); const fontCatalog = getFontCatalog();
const fontLifecycleManager = getFontLifecycleManager();
const isLoading = $derived<boolean>(fontCatalog?.isLoading); const isLoading = $derived<boolean>(fontCatalog?.isLoading);
const isFetching = $derived<boolean>(fontCatalog.isFetching); const isFetching = $derived<boolean>(fontCatalog.isFetching);
@@ -20,9 +20,10 @@ import {
} from '$entities/Font'; } from '$entities/Font';
import { import {
type FontCatalogStore, type FontCatalogStore,
type FontLifecycleManager,
FontsByIdsStore, FontsByIdsStore,
fontLifecycleManager,
getFontCatalog, getFontCatalog,
getFontLifecycleManager,
} from '$entities/Font/model'; } from '$entities/Font/model';
import { import {
type TypographySettingsStore, type TypographySettingsStore,
@@ -106,12 +107,15 @@ export class ComparisonStore {
#typography: TypographySettingsStore; #typography: TypographySettingsStore;
#lifecycle: FontLifecycleManager;
constructor() { constructor() {
// Synchronously seed the batch store with any IDs already in storage // Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value; const { fontAId, fontBId } = storage.value;
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []); this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
this.#fontCatalog = getFontCatalog(); this.#fontCatalog = getFontCatalog();
this.#typography = getTypographySettingsStore(); this.#typography = getTypographySettingsStore();
this.#lifecycle = getFontLifecycleManager();
$effect.root(() => { $effect.root(() => {
// Sync batch results → fontA / fontB // Sync batch results → fontA / fontB
@@ -161,7 +165,7 @@ export class ComparisonStore {
}); });
if (configs.length > 0) { if (configs.length > 0) {
fontLifecycleManager.touch(configs); this.#lifecycle.touch(configs);
this.#checkFontsLoaded(); this.#checkFontsLoaded();
} }
}); });
@@ -203,17 +207,17 @@ export class ComparisonStore {
const fb = this.#fontB; const fb = this.#fontB;
const w = this.#typography.weight; const w = this.#typography.weight;
if (fa) { if (fa) {
fontLifecycleManager.pin(fa.id, w, fa.features?.isVariable); this.#lifecycle.pin(fa.id, w, fa.features?.isVariable);
} }
if (fb) { if (fb) {
fontLifecycleManager.pin(fb.id, w, fb.features?.isVariable); this.#lifecycle.pin(fb.id, w, fb.features?.isVariable);
} }
return () => { return () => {
if (fa) { if (fa) {
fontLifecycleManager.unpin(fa.id, w, fa.features?.isVariable); this.#lifecycle.unpin(fa.id, w, fa.features?.isVariable);
} }
if (fb) { if (fb) {
fontLifecycleManager.unpin(fb.id, w, fb.features?.isVariable); this.#lifecycle.unpin(fb.id, w, fb.features?.isVariable);
} }
}; };
}); });
@@ -63,6 +63,14 @@ vi.mock('$entities/Font', async importOriginal => {
}; };
}); });
const mockLifecycle = vi.hoisted(() => ({
touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
}));
// Stores moved behind the model segment; mock them there. FontsByIdsStore is // Stores moved behind the model segment; mock them there. FontsByIdsStore is
// intentionally left real (spread from actual) so $state reactivity works. // intentionally left real (spread from actual) so $state reactivity works.
vi.mock('$entities/Font/model', async importOriginal => { vi.mock('$entities/Font/model', async importOriginal => {
@@ -70,13 +78,7 @@ vi.mock('$entities/Font/model', async importOriginal => {
return { return {
...actual, ...actual,
getFontCatalog: () => mockFontCatalog, getFontCatalog: () => mockFontCatalog,
fontLifecycleManager: { getFontLifecycleManager: () => mockLifecycle,
touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
},
}; };
}); });
@@ -100,10 +102,7 @@ vi.mock('$features/AdjustTypography/model', () => ({
})); }));
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts'; import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { import { getFontCatalog } from '$entities/Font/model';
fontLifecycleManager,
getFontCatalog,
} from '$entities/Font/model';
import { __resetFontCatalog } from '$entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte'; import { __resetFontCatalog } from '$entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte';
import { ComparisonStore } from './comparisonStore.svelte'; import { ComparisonStore } from './comparisonStore.svelte';
@@ -273,12 +272,12 @@ describe('ComparisonStore', () => {
new ComparisonStore(); new ComparisonStore();
await vi.waitFor(() => { await vi.waitFor(() => {
expect(fontLifecycleManager.pin).toHaveBeenCalledWith( expect(mockLifecycle.pin).toHaveBeenCalledWith(
mockFontA.id, mockFontA.id,
400, 400,
mockFontA.features?.isVariable, mockFontA.features?.isVariable,
); );
expect(fontLifecycleManager.pin).toHaveBeenCalledWith( expect(mockLifecycle.pin).toHaveBeenCalledWith(
mockFontB.id, mockFontB.id,
400, 400,
mockFontB.features?.isVariable, mockFontB.features?.isVariable,
@@ -299,12 +298,12 @@ describe('ComparisonStore', () => {
store.fontA = mockFontC; store.fontA = mockFontC;
await vi.waitFor(() => { await vi.waitFor(() => {
expect(fontLifecycleManager.unpin).toHaveBeenCalledWith( expect(mockLifecycle.unpin).toHaveBeenCalledWith(
mockFontA.id, mockFontA.id,
400, 400,
mockFontA.features?.isVariable, mockFontA.features?.isVariable,
); );
expect(fontLifecycleManager.pin).toHaveBeenCalledWith( expect(mockLifecycle.pin).toHaveBeenCalledWith(
mockFontC.id, mockFontC.id,
400, 400,
mockFontC.features?.isVariable, mockFontC.features?.isVariable,
@@ -11,8 +11,8 @@ import {
VIRTUAL_INDEX_NOT_LOADED, VIRTUAL_INDEX_NOT_LOADED,
} from '$entities/Font'; } from '$entities/Font';
import { import {
fontLifecycleManager,
getFontCatalog, getFontCatalog,
getFontLifecycleManager,
} from '$entities/Font/model'; } from '$entities/Font/model';
import { getSkeletonWidth } from '$shared/lib/utils'; import { getSkeletonWidth } from '$shared/lib/utils';
import { import {
@@ -33,6 +33,7 @@ import {
const fontCatalog = getFontCatalog(); const fontCatalog = getFontCatalog();
const comparisonStore = getComparisonStore(); const comparisonStore = getComparisonStore();
const fontLifecycleManager = getFontLifecycleManager();
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts); const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
const side = $derived<Side>(comparisonStore.side); const side = $derived<Side>(comparisonStore.side);
@@ -10,8 +10,8 @@ import {
createFontRowSizeResolver, createFontRowSizeResolver,
} from '$entities/Font'; } from '$entities/Font';
import { import {
fontLifecycleManager,
getFontCatalog, getFontCatalog,
getFontLifecycleManager,
} from '$entities/Font/model'; } from '$entities/Font/model';
import { import {
TypographyMenu, TypographyMenu,
@@ -24,6 +24,7 @@ import { layoutManager } from '../../model';
const fontCatalog = getFontCatalog(); const fontCatalog = getFontCatalog();
const typographySettingsStore = getTypographySettingsStore(); const typographySettingsStore = getTypographySettingsStore();
const fontLifecycleManager = getFontLifecycleManager();
// FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte. // FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte.
// Header: py-3 (12+12px padding) + ~32px content row ≈ 56px. // Header: py-3 (12+12px padding) + ~32px content row ≈ 56px.