refactor(fontCatalogStore): single source of truth for query params
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.
This commit is contained in:
@@ -84,9 +84,10 @@ describe('FontCatalogStore', () => {
|
|||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts with isEmpty false — initial fetch is in progress', () => {
|
it('starts with isEmpty false — observer is gated until setParams enables it', () => {
|
||||||
// The observer starts fetching immediately on construction.
|
// The observer is disabled on construction (no auto-fetch) — see
|
||||||
// isEmpty must be false so the UI shows a loader, not "no results".
|
// `#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();
|
const store = makeStore();
|
||||||
expect(store.isEmpty).toBe(false);
|
expect(store.isEmpty).toBe(false);
|
||||||
store.destroy();
|
store.destroy();
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsRespon
|
|||||||
|
|
||||||
export class FontCatalogStore {
|
export class FontCatalogStore {
|
||||||
#params = $state<FontStoreParams>({ limit: 50 });
|
#params = $state<FontStoreParams>({ 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<FontStoreResult>({} as FontStoreResult);
|
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||||
#observer: InfiniteQueryObserver<
|
#observer: InfiniteQueryObserver<
|
||||||
ProxyFontsResponse,
|
ProxyFontsResponse,
|
||||||
@@ -45,6 +52,8 @@ export class FontCatalogStore {
|
|||||||
constructor(params: FontStoreParams = {}) {
|
constructor(params: FontStoreParams = {}) {
|
||||||
this.#params = { limit: 50, ...params };
|
this.#params = { limit: 50, ...params };
|
||||||
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
|
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.#unsubscribe = this.#observer.subscribe(r => {
|
||||||
this.#result = r;
|
this.#result = r;
|
||||||
});
|
});
|
||||||
@@ -88,10 +97,13 @@ export class FontCatalogStore {
|
|||||||
return this.#result.error ?? null;
|
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 {
|
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<FontStoreParams>) {
|
setParams(updates: Partial<FontStoreParams>) {
|
||||||
this.#params = { ...this.#params, ...updates };
|
this.#params = { ...this.#params, ...updates };
|
||||||
|
this.#enabled = true;
|
||||||
this.#observer.setOptions(this.buildOptions());
|
this.#observer.setOptions(this.buildOptions());
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -431,6 +445,7 @@ export class FontCatalogStore {
|
|||||||
const next = lastPage.offset + lastPage.limit;
|
const next = lastPage.offset + lastPage.limit;
|
||||||
return next < lastPage.total ? { offset: next } : undefined;
|
return next < lastPage.total ? { offset: next } : undefined;
|
||||||
},
|
},
|
||||||
|
enabled: this.#enabled,
|
||||||
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
|
staleTime: hasFilters ? 0 : DEFAULT_QUERY_STALE_TIME_MS,
|
||||||
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
gcTime: DEFAULT_QUERY_GC_TIME_MS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
* untrack the write so fontCatalogStore's internal $state reads don't feed back
|
||||||
* into this effect's dependency graph.
|
* into this effect's dependency graph.
|
||||||
*/
|
*/
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const params = mapAppliedFiltersToParams(appliedFilterStore);
|
const params = mapAppliedFiltersToParams(appliedFilterStore);
|
||||||
untrack(() => fontCatalogStore.setParams(params));
|
const sort = sortStore.apiValue;
|
||||||
});
|
untrack(() => fontCatalogStore.setParams({ ...params, sort }));
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirror sort selection into fontCatalogStore.
|
|
||||||
*/
|
|
||||||
$effect(() => {
|
|
||||||
const apiSort = sortStore.apiValue;
|
|
||||||
untrack(() => fontCatalogStore.setSort(apiSort));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user