From 0b675635b394e77a1c7b979ec87ad8fd81af5721 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 1 Jun 2026 18:37:18 +0300 Subject: [PATCH] refactor(filters): replace filter/sort store singletons with lazy accessors Convert appliedFilterStore, availableFilterStore and sortStore from eager module-level singletons to getAppliedFilterStore/getAvailableFilterStore/ getSortStore lazy accessors (+ __reset* helpers for tests), so the availableFilterStore QueryObserver is built on first use rather than at import. Update barrels, the startFilterBindings bridge, and all consumers. Reactive reads in components are wrapped in $derived; two-way bind:value targets resolve the accessor once and bind directly (a $derived is read-only). --- src/features/FilterAndSortFonts/index.ts | 12 +++++------ .../FilterAndSortFonts/model/index.ts | 20 +++++++++---------- .../appliedFilterStore.svelte.ts | 19 +++++++++++++----- .../availableFilterStore.svelte.ts | 15 ++++++++++++-- .../model/store/bindings.svelte.ts | 10 +++++++--- .../model/store/sortStore/sortStore.svelte.ts | 16 ++++++++++++++- .../model/store/sortStore/sortStore.test.ts | 16 +++++++++++++-- .../ui/Filters/Filters.svelte | 7 +++++-- .../ui/Filters/Filters.svelte.test.ts | 14 ++++++------- .../ui/FiltersControl/FilterControls.svelte | 10 +++++++--- .../ComparisonView/ui/Search/Search.svelte | 4 +++- .../ui/Search/Search.svelte.test.ts | 6 +++--- .../ui/FontSearch/FontSearch.svelte | 4 +++- 13 files changed, 107 insertions(+), 46 deletions(-) diff --git a/src/features/FilterAndSortFonts/index.ts b/src/features/FilterAndSortFonts/index.ts index c76c3c2..d0535aa 100644 --- a/src/features/FilterAndSortFonts/index.ts +++ b/src/features/FilterAndSortFonts/index.ts @@ -1,21 +1,21 @@ export { mapAppliedFiltersToParams } from './lib'; export { - appliedFilterStore, - /** - * Filter Store - */ - availableFilterStore, /** * Filter Manager */ createAppliedFilterStore, + /** + * Lazy store accessors + */ + getAppliedFilterStore, + getAvailableFilterStore, + getSortStore, /** * Sort Store */ SORT_MAP, SORT_OPTIONS, - sortStore, startFilterBindings, } from './model'; diff --git a/src/features/FilterAndSortFonts/model/index.ts b/src/features/FilterAndSortFonts/model/index.ts index 49ad32a..6dcafff 100644 --- a/src/features/FilterAndSortFonts/model/index.ts +++ b/src/features/FilterAndSortFonts/model/index.ts @@ -14,9 +14,9 @@ export type { */ export { /** - * Low-level property selection store + * Lazy accessor for the app-wide filter-metadata store */ - availableFilterStore, + getAvailableFilterStore, } from './store/availableFilterStore/availableFilterStore.svelte'; /** @@ -27,14 +27,14 @@ export { * Reactive interface returned by `createAppliedFilterStore` */ type AppliedFilterStore, - /** - * High-level manager for syncing search and filters - */ - appliedFilterStore, /** * Factory for constructing a filter manager instance */ createAppliedFilterStore, + /** + * Lazy accessor for the app-wide filter manager + */ + getAppliedFilterStore, } from './store/appliedFilterStore/appliedFilterStore.svelte'; /** @@ -47,6 +47,10 @@ export { startFilterBindings } from './store/bindings.svelte'; * Sorting logic */ export { + /** + * Lazy accessor for the app-wide sort store + */ + getSortStore, /** * Map of human-readable labels to API sort keys */ @@ -63,8 +67,4 @@ export { * UI model for a single sort option */ type SortOption, - /** - * Reactive store for the current sort selection - */ - sortStore, } from './store/sortStore/sortStore.svelte'; diff --git a/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts b/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts index 11a65cc..522dd3d 100644 --- a/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts @@ -129,14 +129,23 @@ export function createAppliedFilterStore(config: FilterCo export type AppliedFilterStore = ReturnType; +let _appliedFilterStore: AppliedFilterStore | undefined; + /** - * App-wide filter manager singleton. + * App-wide filter manager, created on first access. * * Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring * lives in `./bindings.svelte` and populates groups once backend filter * metadata arrives. */ -export const appliedFilterStore = createAppliedFilterStore({ - queryValue: '', - groups: [], -}); +export function getAppliedFilterStore(): AppliedFilterStore { + return (_appliedFilterStore ??= createAppliedFilterStore({ + queryValue: '', + groups: [], + })); +} + +// test-only reset, so specs don't share filter/selection state +export function __resetAppliedFilterStore() { + _appliedFilterStore = undefined; +} diff --git a/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.svelte.ts b/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.svelte.ts index 7eff164..265b153 100644 --- a/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/availableFilterStore/availableFilterStore.svelte.ts @@ -126,7 +126,18 @@ export class AvailableFilterStore { } } +let _availableFilterStore: AvailableFilterStore | undefined; + /** - * Singleton instance + * App-wide filter-metadata store, created on first access. Lazy so the + * QueryObserver isn't constructed at module load. */ -export const availableFilterStore = new AvailableFilterStore(); +export function getAvailableFilterStore(): AvailableFilterStore { + return (_availableFilterStore ??= new AvailableFilterStore()); +} + +// test-only reset, so specs don't share a live observer +export function __resetAvailableFilterStore() { + _availableFilterStore?.destroy(); + _availableFilterStore = undefined; +} diff --git a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts index 537d5d8..f5491bb 100644 --- a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts @@ -13,11 +13,15 @@ import { getFontCatalog } from '$entities/Font/model'; import { untrack } from 'svelte'; import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams'; import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups'; -import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte'; -import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte'; -import { sortStore } from './sortStore/sortStore.svelte'; +import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte'; +import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte'; +import { getSortStore } from './sortStore/sortStore.svelte'; export function startFilterBindings(): () => void { + const appliedFilterStore = getAppliedFilterStore(); + const availableFilterStore = getAvailableFilterStore(); + const sortStore = getSortStore(); + const stop = $effect.root(() => { $effect(() => { const dynamicFilters = availableFilterStore.filters; diff --git a/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.svelte.ts b/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.svelte.ts index 3a8908f..5d2a8ca 100644 --- a/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.svelte.ts @@ -44,4 +44,18 @@ export function createSortStore(initial: SortOption = 'Popularity') { }; } -export const sortStore = createSortStore(); +export type SortStore = ReturnType; + +let _sortStore: SortStore | undefined; + +/** + * App-wide sort store, created on first access. + */ +export function getSortStore(): SortStore { + return (_sortStore ??= createSortStore()); +} + +// test-only reset, so specs don't share selection state +export function __resetSortStore() { + _sortStore = undefined; +} diff --git a/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.test.ts b/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.test.ts index fcdc41b..b78cbc8 100644 --- a/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.test.ts +++ b/src/features/FilterAndSortFonts/model/store/sortStore/sortStore.test.ts @@ -1,4 +1,5 @@ import { + afterEach, describe, expect, it, @@ -7,8 +8,9 @@ import { SORT_MAP, SORT_OPTIONS, type SortOption, + __resetSortStore, createSortStore, - sortStore, + getSortStore, } from './sortStore.svelte'; describe('createSortStore', () => { @@ -51,14 +53,24 @@ describe('createSortStore', () => { }); }); -describe('sortStore singleton', () => { +describe('getSortStore singleton', () => { + afterEach(() => { + __resetSortStore(); + }); + + it('returns the same instance across calls', () => { + expect(getSortStore()).toBe(getSortStore()); + }); + it('exposes the same shape as a factory instance', () => { + const sortStore = getSortStore(); expect(typeof sortStore.value).toBe('string'); expect(typeof sortStore.apiValue).toBe('string'); expect(typeof sortStore.set).toBe('function'); }); it('accepts all SORT_OPTIONS as valid set() inputs', () => { + const sortStore = getSortStore(); for (const option of SORT_OPTIONS) { sortStore.set(option); expect(sortStore.value).toBe(option); diff --git a/src/features/FilterAndSortFonts/ui/Filters/Filters.svelte b/src/features/FilterAndSortFonts/ui/Filters/Filters.svelte index b993c48..ec993d9 100644 --- a/src/features/FilterAndSortFonts/ui/Filters/Filters.svelte +++ b/src/features/FilterAndSortFonts/ui/Filters/Filters.svelte @@ -4,10 +4,13 @@ --> -{#each appliedFilterStore.groups as group (group.id)} +{#each groups as group (group.id)} { beforeEach(() => { // Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us - appliedFilterStore.setGroups([]); - vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]); + getAppliedFilterStore().setGroups([]); + vi.spyOn(getAvailableFilterStore(), 'filters', 'get').mockReturnValue([]); }); afterEach(() => { @@ -28,7 +28,7 @@ describe('Filters', () => { }); it('renders a label for each filter group', () => { - appliedFilterStore.setGroups([ + getAppliedFilterStore().setGroups([ { id: 'cat', label: 'Categories', properties: [] }, { id: 'prov', label: 'Font Providers', properties: [] }, ]); @@ -38,7 +38,7 @@ describe('Filters', () => { }); it('renders filter properties within groups', () => { - appliedFilterStore.setGroups([ + getAppliedFilterStore().setGroups([ { id: 'cat', label: 'Category', @@ -54,7 +54,7 @@ describe('Filters', () => { }); it('renders multiple groups with their properties', () => { - appliedFilterStore.setGroups([ + getAppliedFilterStore().setGroups([ { id: 'cat', label: 'Category', diff --git a/src/features/FilterAndSortFonts/ui/FiltersControl/FilterControls.svelte b/src/features/FilterAndSortFonts/ui/FiltersControl/FilterControls.svelte index f249f99..08c4fc9 100644 --- a/src/features/FilterAndSortFonts/ui/FiltersControl/FilterControls.svelte +++ b/src/features/FilterAndSortFonts/ui/FiltersControl/FilterControls.svelte @@ -12,8 +12,8 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'; import { getContext } from 'svelte'; import { SORT_OPTIONS, - appliedFilterStore, - sortStore, + getAppliedFilterStore, + getSortStore, } from '../../model'; interface Props { @@ -30,6 +30,10 @@ const { const responsive = getContext('responsive'); const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait); +const appliedFilterStore = getAppliedFilterStore(); +const sortStore = getSortStore(); +const sortValue = $derived(sortStore.value); + function handleReset() { appliedFilterStore.deselectAllGlobal(); } @@ -53,7 +57,7 @@ function handleReset() {