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).
This commit is contained in:
@@ -1,21 +1,21 @@
|
|||||||
export { mapAppliedFiltersToParams } from './lib';
|
export { mapAppliedFiltersToParams } from './lib';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
appliedFilterStore,
|
|
||||||
/**
|
|
||||||
* Filter Store
|
|
||||||
*/
|
|
||||||
availableFilterStore,
|
|
||||||
/**
|
/**
|
||||||
* Filter Manager
|
* Filter Manager
|
||||||
*/
|
*/
|
||||||
createAppliedFilterStore,
|
createAppliedFilterStore,
|
||||||
|
/**
|
||||||
|
* Lazy store accessors
|
||||||
|
*/
|
||||||
|
getAppliedFilterStore,
|
||||||
|
getAvailableFilterStore,
|
||||||
|
getSortStore,
|
||||||
/**
|
/**
|
||||||
* Sort Store
|
* Sort Store
|
||||||
*/
|
*/
|
||||||
SORT_MAP,
|
SORT_MAP,
|
||||||
SORT_OPTIONS,
|
SORT_OPTIONS,
|
||||||
sortStore,
|
|
||||||
startFilterBindings,
|
startFilterBindings,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export type {
|
|||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
/**
|
/**
|
||||||
* Low-level property selection store
|
* Lazy accessor for the app-wide filter-metadata store
|
||||||
*/
|
*/
|
||||||
availableFilterStore,
|
getAvailableFilterStore,
|
||||||
} from './store/availableFilterStore/availableFilterStore.svelte';
|
} from './store/availableFilterStore/availableFilterStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,14 +27,14 @@ export {
|
|||||||
* Reactive interface returned by `createAppliedFilterStore`
|
* Reactive interface returned by `createAppliedFilterStore`
|
||||||
*/
|
*/
|
||||||
type AppliedFilterStore,
|
type AppliedFilterStore,
|
||||||
/**
|
|
||||||
* High-level manager for syncing search and filters
|
|
||||||
*/
|
|
||||||
appliedFilterStore,
|
|
||||||
/**
|
/**
|
||||||
* Factory for constructing a filter manager instance
|
* Factory for constructing a filter manager instance
|
||||||
*/
|
*/
|
||||||
createAppliedFilterStore,
|
createAppliedFilterStore,
|
||||||
|
/**
|
||||||
|
* Lazy accessor for the app-wide filter manager
|
||||||
|
*/
|
||||||
|
getAppliedFilterStore,
|
||||||
} from './store/appliedFilterStore/appliedFilterStore.svelte';
|
} from './store/appliedFilterStore/appliedFilterStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +47,10 @@ export { startFilterBindings } from './store/bindings.svelte';
|
|||||||
* Sorting logic
|
* Sorting logic
|
||||||
*/
|
*/
|
||||||
export {
|
export {
|
||||||
|
/**
|
||||||
|
* Lazy accessor for the app-wide sort store
|
||||||
|
*/
|
||||||
|
getSortStore,
|
||||||
/**
|
/**
|
||||||
* Map of human-readable labels to API sort keys
|
* Map of human-readable labels to API sort keys
|
||||||
*/
|
*/
|
||||||
@@ -63,8 +67,4 @@ export {
|
|||||||
* UI model for a single sort option
|
* UI model for a single sort option
|
||||||
*/
|
*/
|
||||||
type SortOption,
|
type SortOption,
|
||||||
/**
|
|
||||||
* Reactive store for the current sort selection
|
|
||||||
*/
|
|
||||||
sortStore,
|
|
||||||
} from './store/sortStore/sortStore.svelte';
|
} from './store/sortStore/sortStore.svelte';
|
||||||
|
|||||||
+12
-3
@@ -129,14 +129,23 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
|
|||||||
|
|
||||||
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
|
||||||
|
|
||||||
|
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
|
* Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring
|
||||||
* lives in `./bindings.svelte` and populates groups once backend filter
|
* lives in `./bindings.svelte` and populates groups once backend filter
|
||||||
* metadata arrives.
|
* metadata arrives.
|
||||||
*/
|
*/
|
||||||
export const appliedFilterStore = createAppliedFilterStore({
|
export function getAppliedFilterStore(): AppliedFilterStore {
|
||||||
|
return (_appliedFilterStore ??= createAppliedFilterStore<string>({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: [],
|
groups: [],
|
||||||
});
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share filter/selection state
|
||||||
|
export function __resetAppliedFilterStore() {
|
||||||
|
_appliedFilterStore = undefined;
|
||||||
|
}
|
||||||
|
|||||||
+13
-2
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ import { getFontCatalog } from '$entities/Font/model';
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams';
|
||||||
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
|
import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups';
|
||||||
import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
|
||||||
import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
|
import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
|
||||||
import { sortStore } from './sortStore/sortStore.svelte';
|
import { getSortStore } from './sortStore/sortStore.svelte';
|
||||||
|
|
||||||
export function startFilterBindings(): () => void {
|
export function startFilterBindings(): () => void {
|
||||||
|
const appliedFilterStore = getAppliedFilterStore();
|
||||||
|
const availableFilterStore = getAvailableFilterStore();
|
||||||
|
const sortStore = getSortStore();
|
||||||
|
|
||||||
const stop = $effect.root(() => {
|
const stop = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const dynamicFilters = availableFilterStore.filters;
|
const dynamicFilters = availableFilterStore.filters;
|
||||||
|
|||||||
@@ -44,4 +44,18 @@ export function createSortStore(initial: SortOption = 'Popularity') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sortStore = createSortStore();
|
export type SortStore = ReturnType<typeof createSortStore>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
afterEach,
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
it,
|
it,
|
||||||
@@ -7,8 +8,9 @@ import {
|
|||||||
SORT_MAP,
|
SORT_MAP,
|
||||||
SORT_OPTIONS,
|
SORT_OPTIONS,
|
||||||
type SortOption,
|
type SortOption,
|
||||||
|
__resetSortStore,
|
||||||
createSortStore,
|
createSortStore,
|
||||||
sortStore,
|
getSortStore,
|
||||||
} from './sortStore.svelte';
|
} from './sortStore.svelte';
|
||||||
|
|
||||||
describe('createSortStore', () => {
|
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', () => {
|
it('exposes the same shape as a factory instance', () => {
|
||||||
|
const sortStore = getSortStore();
|
||||||
expect(typeof sortStore.value).toBe('string');
|
expect(typeof sortStore.value).toBe('string');
|
||||||
expect(typeof sortStore.apiValue).toBe('string');
|
expect(typeof sortStore.apiValue).toBe('string');
|
||||||
expect(typeof sortStore.set).toBe('function');
|
expect(typeof sortStore.set).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
|
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
|
||||||
|
const sortStore = getSortStore();
|
||||||
for (const option of SORT_OPTIONS) {
|
for (const option of SORT_OPTIONS) {
|
||||||
sortStore.set(option);
|
sortStore.set(option);
|
||||||
expect(sortStore.value).toBe(option);
|
expect(sortStore.value).toBe(option);
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { FilterGroup } from '$shared/ui';
|
import { FilterGroup } from '$shared/ui';
|
||||||
import { appliedFilterStore } from '../../model';
|
import { getAppliedFilterStore } from '../../model';
|
||||||
|
|
||||||
|
const appliedFilterStore = getAppliedFilterStore();
|
||||||
|
const groups = $derived(appliedFilterStore.groups);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each appliedFilterStore.groups as group (group.id)}
|
{#each groups as group (group.id)}
|
||||||
<FilterGroup
|
<FilterGroup
|
||||||
displayedLabel={group.label}
|
displayedLabel={group.label}
|
||||||
filter={group.instance}
|
filter={group.instance}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
appliedFilterStore,
|
getAppliedFilterStore,
|
||||||
availableFilterStore,
|
getAvailableFilterStore,
|
||||||
} from '$features/FilterAndSortFonts';
|
} from '$features/FilterAndSortFonts';
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
@@ -12,8 +12,8 @@ import Filters from './Filters.svelte';
|
|||||||
describe('Filters', () => {
|
describe('Filters', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
|
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
|
||||||
appliedFilterStore.setGroups([]);
|
getAppliedFilterStore().setGroups([]);
|
||||||
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
|
vi.spyOn(getAvailableFilterStore(), 'filters', 'get').mockReturnValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -28,7 +28,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders a label for each filter group', () => {
|
it('renders a label for each filter group', () => {
|
||||||
appliedFilterStore.setGroups([
|
getAppliedFilterStore().setGroups([
|
||||||
{ id: 'cat', label: 'Categories', properties: [] },
|
{ id: 'cat', label: 'Categories', properties: [] },
|
||||||
{ id: 'prov', label: 'Font Providers', properties: [] },
|
{ id: 'prov', label: 'Font Providers', properties: [] },
|
||||||
]);
|
]);
|
||||||
@@ -38,7 +38,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders filter properties within groups', () => {
|
it('renders filter properties within groups', () => {
|
||||||
appliedFilterStore.setGroups([
|
getAppliedFilterStore().setGroups([
|
||||||
{
|
{
|
||||||
id: 'cat',
|
id: 'cat',
|
||||||
label: 'Category',
|
label: 'Category',
|
||||||
@@ -54,7 +54,7 @@ describe('Filters', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders multiple groups with their properties', () => {
|
it('renders multiple groups with their properties', () => {
|
||||||
appliedFilterStore.setGroups([
|
getAppliedFilterStore().setGroups([
|
||||||
{
|
{
|
||||||
id: 'cat',
|
id: 'cat',
|
||||||
label: 'Category',
|
label: 'Category',
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
|||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import {
|
import {
|
||||||
SORT_OPTIONS,
|
SORT_OPTIONS,
|
||||||
appliedFilterStore,
|
getAppliedFilterStore,
|
||||||
sortStore,
|
getSortStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -30,6 +30,10 @@ const {
|
|||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||||
|
|
||||||
|
const appliedFilterStore = getAppliedFilterStore();
|
||||||
|
const sortStore = getSortStore();
|
||||||
|
const sortValue = $derived(sortStore.value);
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
appliedFilterStore.deselectAllGlobal();
|
appliedFilterStore.deselectAllGlobal();
|
||||||
}
|
}
|
||||||
@@ -53,7 +57,7 @@ function handleReset() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||||
active={sortStore.value === option}
|
active={sortValue === option}
|
||||||
onclick={() => sortStore.set(option)}
|
onclick={() => sortStore.set(option)}
|
||||||
class="tracking-wide px-0"
|
class="tracking-wide px-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
propagates the value into fontCatalogStore.
|
propagates the value into fontCatalogStore.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { appliedFilterStore } from '$features/FilterAndSortFonts';
|
import { getAppliedFilterStore } from '$features/FilterAndSortFonts';
|
||||||
import { SearchBar } from '$shared/ui';
|
import { SearchBar } from '$shared/ui';
|
||||||
|
|
||||||
|
const appliedFilterStore = getAppliedFilterStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-6 border-b border-subtle">
|
<div class="p-6 border-b border-subtle">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { appliedFilterStore } from '$features/FilterAndSortFonts';
|
import { getAppliedFilterStore } from '$features/FilterAndSortFonts';
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
@@ -7,7 +7,7 @@ import Search from './Search.svelte';
|
|||||||
|
|
||||||
describe('Search', () => {
|
describe('Search', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
appliedFilterStore.queryValue = '';
|
getAppliedFilterStore().queryValue = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
@@ -24,7 +24,7 @@ describe('Search', () => {
|
|||||||
|
|
||||||
describe('Value binding', () => {
|
describe('Value binding', () => {
|
||||||
it('reflects appliedFilterStore.queryValue as initial value', () => {
|
it('reflects appliedFilterStore.queryValue as initial value', () => {
|
||||||
appliedFilterStore.queryValue = 'Inter';
|
getAppliedFilterStore().queryValue = 'Inter';
|
||||||
render(Search);
|
render(Search);
|
||||||
expect(screen.getByRole('textbox')).toHaveValue('Inter');
|
expect(screen.getByRole('textbox')).toHaveValue('Inter');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import {
|
import {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
appliedFilterStore,
|
getAppliedFilterStore,
|
||||||
} from '$features/FilterAndSortFonts';
|
} from '$features/FilterAndSortFonts';
|
||||||
import { springySlideFade } from '$shared/lib';
|
import { springySlideFade } from '$shared/lib';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +31,8 @@ interface Props {
|
|||||||
|
|
||||||
let { showFilters = $bindable(true) }: Props = $props();
|
let { showFilters = $bindable(true) }: Props = $props();
|
||||||
|
|
||||||
|
const appliedFilterStore = getAppliedFilterStore();
|
||||||
|
|
||||||
const transform = new Tween(
|
const transform = new Tween(
|
||||||
{ scale: 1, rotate: 0 },
|
{ scale: 1, rotate: 0 },
|
||||||
{ duration: 250, easing: cubicOut },
|
{ duration: 250, easing: cubicOut },
|
||||||
|
|||||||
Reference in New Issue
Block a user