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() {