Fixes/request deduplication #44

Merged
ilia merged 4 commits from fixes/request-deduplication into main 2026-05-28 19:54:58 +00:00
3 changed files with 31 additions and 16 deletions
Showing only changes of commit 5d72bb7a4c - Show all commits
@@ -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();
@@ -31,6 +31,13 @@ type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsRespon
export class FontCatalogStore {
#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);
#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<FontStoreParams>) {
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,
};
@@ -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 }));
});
});