feature/unified-tanstack-query #36

Merged
ilia merged 8 commits from feature/unified-tanstack-query into main 2026-04-16 04:53:29 +00:00
2 changed files with 136 additions and 0 deletions
Showing only changes of commit 8e88d1b7cf - Show all commits

View File

@@ -0,0 +1,51 @@
import { queryClient } from '$shared/api/queryClient';
import {
QueryObserver,
type QueryObserverResult,
type QueryOptions,
} from '@tanstack/query-core';
/**
* Abstract base class for reactive Svelte 5 stores backed by TanStack Query.
*
* Provides a unified way to use TanStack Query observers within Svelte 5 classes
* using runes for reactivity. Handles subscription lifecycle automatically.
*
* @template TData - The type of data returned by the query.
* @template TError - The type of error that can be thrown.
*/
export abstract class BaseQueryStore<TData, TError = Error> {
#result = $state<QueryObserverResult<TData, TError>>({} as QueryObserverResult<TData, TError>);
#observer: QueryObserver<TData, TError>;
#unsubscribe: () => void;
constructor(options: QueryOptions<TData, TError, any, any, any>) {
this.#observer = new QueryObserver(queryClient, options);
this.#unsubscribe = this.#observer.subscribe(result => {
this.#result = result;
});
}
/**
* Current query result (reactive)
*/
protected get result(): QueryObserverResult<TData, TError> {
return this.#result;
}
/**
* Updates observer options dynamically.
* Use this when query parameters or dependencies change.
*/
protected updateOptions(options: QueryOptions<TData, TError, any, any, any>): void {
this.#observer.setOptions(options);
}
/**
* Cleans up the observer subscription.
* Should be called when the store is no longer needed.
*/
destroy(): void {
this.#unsubscribe();
}
}

View File

@@ -0,0 +1,85 @@
import { queryClient } from '$shared/api/queryClient';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { BaseQueryStore } from './BaseQueryStore.svelte';
class TestStore extends BaseQueryStore<string> {
constructor(key = ['test'], fn = () => Promise.resolve('ok')) {
super({
queryKey: key,
queryFn: fn,
retry: false, // Disable retries for faster error testing
});
}
get data() {
return this.result.data;
}
get isLoading() {
return this.result.isLoading;
}
get isError() {
return this.result.isError;
}
update(newKey: string[], newFn?: () => Promise<string>) {
this.updateOptions({
queryKey: newKey,
queryFn: newFn ?? (() => Promise.resolve('ok')),
retry: false,
});
}
}
import * as tq from '@tanstack/query-core';
// ... (TestStore remains same)
describe('BaseQueryStore', () => {
beforeEach(() => {
queryClient.clear();
});
describe('Lifecycle & Fetching', () => {
it('should transition from loading to success', async () => {
const store = new TestStore();
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.data).toBe('ok'), { timeout: 1000 });
expect(store.isLoading).toBe(false);
});
});
describe('Error Handling', () => {
it('should handle query failures', async () => {
const store = new TestStore(['fail'], () => Promise.reject(new Error('fail')));
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
});
});
describe('Reactivity', () => {
it('should refetch and update data when options change', async () => {
const store = new TestStore(['key1'], () => Promise.resolve('val1'));
await vi.waitFor(() => expect(store.data).toBe('val1'), { timeout: 1000 });
store.update(['key2'], () => Promise.resolve('val2'));
await vi.waitFor(() => expect(store.data).toBe('val2'), { timeout: 1000 });
});
});
describe('Cleanup', () => {
it('should unsubscribe observer on destroy', () => {
const unsubscribe = vi.fn();
const subscribeSpy = vi.spyOn(tq.QueryObserver.prototype, 'subscribe').mockReturnValue(unsubscribe);
const store = new TestStore();
store.destroy();
expect(unsubscribe).toHaveBeenCalled();
subscribeSpy.mockRestore();
});
});
});