From 5d72bb7a4cabdcd2c21abe397d980103280760b1 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 28 May 2026 21:38:17 +0300 Subject: [PATCH] refactor(fontCatalogStore): single source of truth for query params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On initial load, two separate $effects in bindings.svelte.ts — one for filters, one for sort — each issued its own setOptions with a different queryKey on the first flush, producing an orphaned `/fonts?limit=50&offset=0` request immediately followed by the real `/fonts?limit=50&sort=popularity&offset=0`. Hardcoding the default sort on the singleton would have papered over the symptom while leaving the sortStore default and the catalog-store default coupled by hand. Make bindings the sole emitter of query params: - features/.../bindings: merge filter + sort effects into one. The effect reads both stores, builds the merged param object, and issues a single setParams. No more interleaved setOptions on mount. - entities/.../fontCatalogStore: gate the observer with `enabled: false` on construction. The first setParams flips `#enabled` on and triggers exactly one fetch with the correct queryKey. Removes the need for a hardcoded default sort on the singleton. - isEmpty is also gated on `#enabled` so the brief pre-config window doesn't render "no results" before bindings configures the query. - The constructor seeds #result from observer.getCurrentResult() because subscribe may not fire synchronously when the observer is disabled. --- .../fontCatalogStore.svelte.spec.ts | 7 ++++--- .../fontCatalogStore.svelte.ts | 21 ++++++++++++++++--- .../model/store/bindings.svelte.ts | 19 ++++++++--------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts index f287091..07a4edb 100644 --- a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts +++ b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.spec.ts @@ -84,9 +84,10 @@ describe('FontCatalogStore', () => { store.destroy(); }); - it('starts with isEmpty false — initial fetch is in progress', () => { - // The observer starts fetching immediately on construction. - // isEmpty must be false so the UI shows a loader, not "no results". + it('starts with isEmpty false — observer is gated until setParams enables it', () => { + // The observer is disabled on construction (no auto-fetch) — see + // `#enabled` in the store. isEmpty must still be false so the UI + // doesn't flash "no results" before bindings configures the query. const store = makeStore(); expect(store.isEmpty).toBe(false); store.destroy(); diff --git a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts index c8eae85..c6d7510 100644 --- a/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts +++ b/src/entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte.ts @@ -31,6 +31,13 @@ type FontStoreResult = InfiniteQueryObserverResult({ limit: 50 }); + /** + * Gates the initial fetch. The observer starts disabled so the constructor + * cannot race ahead of the bindings module — which is the single source of + * truth for query params. The first setParams flips this on, producing a + * single fetch with the correctly merged queryKey. + */ + #enabled = $state(false); #result = $state({} as FontStoreResult); #observer: InfiniteQueryObserver< ProxyFontsResponse, @@ -45,6 +52,8 @@ export class FontCatalogStore { constructor(params: FontStoreParams = {}) { this.#params = { limit: 50, ...params }; this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions()); + // Seed result synchronously; subscribe may not fire on disabled observers. + this.#result = this.#observer.getCurrentResult(); this.#unsubscribe = this.#observer.subscribe(r => { this.#result = r; }); @@ -88,10 +97,13 @@ export class FontCatalogStore { return this.#result.error ?? null; } /** - * True if no fonts were found for the current filter criteria + * True if no fonts were found for the current filter criteria. + * Always false until the observer has been enabled (via setParams) — otherwise + * the UI would briefly render "no results" on mount before bindings configures + * the query. */ get isEmpty(): boolean { - return !this.isLoading && !this.isFetching && this.fonts.length === 0; + return this.#enabled && !this.isLoading && !this.isFetching && this.fonts.length === 0; } /** @@ -129,10 +141,12 @@ export class FontCatalogStore { } /** - * Merge new parameters into existing state and trigger a refetch + * Merge new parameters into existing state and trigger a refetch. + * The first call also enables the observer (see `#enabled`). */ setParams(updates: Partial) { this.#params = { ...this.#params, ...updates }; + this.#enabled = true; this.#observer.setOptions(this.buildOptions()); } /** @@ -431,6 +445,7 @@ export class FontCatalogStore { const next = lastPage.offset + lastPage.limit; return next < lastPage.total ? { offset: next } : undefined; }, + enabled: this.#enabled, staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS, gcTime: DEFAULT_QUERY_GC_TIME_MS, }; diff --git a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts index ed955fa..417fc3e 100644 --- a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts @@ -42,20 +42,19 @@ $effect.root(() => { }); /** - * Mirror filter selections + debounced search query into fontCatalogStore params. + * Mirror filter selections + debounced search query + sort into fontCatalogStore params. + * + * Filters and sort are merged into one setParams call to avoid a startup race: + * two separate effects each issued setOptions with a different queryKey on the + * first flush, producing an orphaned `?limit=50&offset=0` fetch immediately + * followed by the real `?limit=50&sort=popularity&offset=0` fetch. + * * untrack the write so fontCatalogStore's internal $state reads don't feed back * into this effect's dependency graph. */ $effect(() => { const params = mapAppliedFiltersToParams(appliedFilterStore); - untrack(() => fontCatalogStore.setParams(params)); - }); - - /** - * Mirror sort selection into fontCatalogStore. - */ - $effect(() => { - const apiSort = sortStore.apiValue; - untrack(() => fontCatalogStore.setSort(apiSort)); + const sort = sortStore.apiValue; + untrack(() => fontCatalogStore.setParams({ ...params, sort })); }); });