import { AxiosError } from 'axios'; import cloneDeep from 'lodash/cloneDeep'; import debounce from 'lodash/debounce'; import forEach from 'lodash/forEach'; import isEmpty from 'lodash/isEmpty'; import take from 'lodash/take'; import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import type { QueryInvalidationOptions } from '../types/query-invalidation-options'; import type { GlobalClientOptions } from './client-helpers'; import { getClientOptions, shouldShareWindowClient } from './client-helpers'; import { QueryClientStore } from './query-client.store'; import type { DefaultedQueryObserverOptions, QueryKey, QueryObserverOptions, QueryObserverResult, } from '@tanstack/query-core'; import { QueryObserver } from '@tanstack/query-core'; /** * Optional interface for the `QueryApiStore` inheritance pattern. * Not needed for the composable `@queryStore` decorator approach — use the * `refreshOnMount()` factory from `@servicetitan/tanstack-query-mobx/composable` instead. * * @remarks * Allows stores to automatically refresh stale data when navigating between components. * {@link QueryApi} and {@link QueryApiStore} implement this interface. * Implementing it in your own store is optional — only needed if you want to * participate in the `storesToRefreshOnMount` chain. * * @see `refreshOnMount()` factory for the composable approach (recommended) */ export interface IRefreshOnMountStore { /** Triggers refresh for all registered dependent stores */ refreshStoresOnMount: () => void; /** Refreshes this store if data is stale */ refreshOnMount: () => void; /** Optional list of dependent stores to refresh on mount */ storesToRefreshOnMount?: () => (IRefreshOnMountStore | undefined)[]; } /** * Configuration options for queries, extending TanStack Query's QueryObserverOptions * with additional integration-specific options. * * @template T - The type of data returned by the query * @template TError - The error type (default: AxiosError) * * @remarks * Extends TanStack Query's standard query options with MobX integration features. * All standard options (queryKey, queryFn, staleTime, enabled, etc.) are available. * Default `staleTime` is 10 minutes (set by `QueryClientStore`). Override per-query as needed. * * @see [TanStack Query QueryOptions](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery) * @see {@link QueryApi} for the query manager that uses these options * @see README.md "Key Concepts" and "Common Patterns" for usage examples * * @example Basic query configuration * ```typescript * get queryOptions(): QueryApiOptions { * return { * queryKey: ['scheduling', 'jobs'], * queryFn: async () => (await this.api?.getJobs())?.data ?? [], * staleTime: 5 * 60 * 1000, // 5 minutes * }; * } * ``` * * @example Data-driven query with dependencies * ```typescript * get queryOptions(): QueryApiOptions { * return { * queryKey: ['scheduling', 'job', this.jobId], // Refetches when jobId changes * queryFn: async () => (await this.api?.getJob(this.jobId))?.data, * enabled: this.jobId > 0, // Only fetch when jobId is valid * }; * } * ``` */ export interface QueryApiOptions extends QueryObserverOptions< T, TError > { /** * Remove the query from the client cache when disposed (default: false). * Useful for queries with sensitive data or very dynamic content. * * - `true`: removes cache using the full query key * - `number`: removes cache using the first N segments of the query key * * @example * ```typescript * disposeQuery: true // Remove exact query from cache when store unmounts * disposeQuery: 2 // Remove all queries matching the first 2 key segments * ``` */ disposeQuery?: boolean | number; /** * Automatically set initialized to true after successful fetch (default: true). * Set to false if you need manual control over initialization state. * * @see {@link QueryApi.initialized} */ autoInitialize?: boolean; /** * Callback invoked when query succeeds with the result data. * * @example * ```typescript * onSuccess: (data) => { * console.log('Loaded', data.length, 'jobs'); * this.processData(data); * } * ``` */ onSuccess?: (data: T) => void; /** * Callback invoked when query fails with the error object. * If not provided, errors are logged to console. * * @example * ```typescript * onError: (error) => { * console.error('Failed to load jobs:', error); * showNotification('Failed to load data'); * } * ``` */ onError?: (_?: TError | null) => void; /** * Use a shared query client for micro-frontend scenarios where multiple app instances * need to share cache. * * Client types: * - `'page'`: Shared across all instances. Uses reference counting - disposes when last page client instance is disposed. * - `'app'`: Shared across all instances. Persists until browser refresh/close. * * @see {@link getQueryClient} for providing shared clients at the provider level (uses 'page' only) * @see {@link GlobalClientOptions} in client-helpers.ts for advanced configuration * @see TESTING.md — ContainerBuilder tests isolate from `window.queryClients` by default * * @example * ```typescript * // Use page-level shared client for this query * globalClient: 'page' * * // Use app-level shared client for this query * globalClient: 'app' * ``` */ globalClient?: GlobalClientOptions; /** * @deprecated Use `dedupe` option on `invalidate()` instead. Debounce will be removed in a future version. */ debounceInvalidateTime?: number; } /** * Core class that manages an individual TanStack Query and syncs its state to MobX observables. * * @template T - The type of data returned by the query * @template TError - The error type (default: AxiosError) * * @remarks * This class bridges TanStack Query's reactive system with MobX observables, * allowing query state to be seamlessly integrated into MobX stores. * * Key features: * - Observable state: `data`, `initialized`, `isLoading`, `isFetching`, `isError`, `error` * - Automatic refetch when `queryKey` dependencies change (via MobX reactions) * - Manual control: `invalidate()`, `updateQueryData()`, `cancel()`, `refetch()` * - Lifecycle management: automatic setup and cleanup * * Typically created via the `query()` factory with the `@queryStore` decorator, * or via `QueryApiStore.addQuery()` in the inheritance approach. * * @see {@link QueryApiOptions} for configuration options * @see `@queryStore` decorator (recommended) for automatic lifecycle management * @see {@link QueryApiStore} for the inheritance-based approach * * @example Recommended: `@queryStore` decorator with `query()` factory * ```typescript * @queryStore * class JobsStore extends Store { * @inject(JobsApi) private api?: JobsApi; * * jobs = query(() => ({ * queryKey: ['scheduling', 'jobs'], * queryFn: async () => (await this.api?.getJobs())?.data ?? [], * })); * * stats = query(() => ({ * queryKey: ['scheduling', 'stats'], * queryFn: async () => (await this.api?.getStats())?.data, * })); * } * ``` * * @example Inheritance: `QueryApiStore` with `addQuery()` * ```typescript * @injectable() * class JobsStore extends QueryApiStore { * @inject(JobsApi) private api?: JobsApi; * * get queryOptions(): QueryApiOptions { * return { * queryKey: ['scheduling', 'jobs'], * queryFn: async () => (await this.api?.getJobs())?.data ?? [], * }; * } * * stats = this.addQuery(() => ({ * queryKey: ['scheduling', 'stats'], * queryFn: async () => (await this.api?.getStats())?.data, * })); * } * ``` */ export class QueryApi implements IRefreshOnMountStore { /** * Becomes true after the first query attempt settles without any active fetch or invalidation. * Unlike TanStack Query's status flags, this is a package-specific readiness flag for store setup. */ @observable initialized = false; /** * Mirrors TanStack Query's `isPending`. * True while the query has no settled result yet, including disabled queries that have never run. */ @observable isPending = false; /** * Mirrors TanStack Query's `isLoading`. * True only while the first fetch is in flight (`isFetching && isPending`). */ @observable isLoading = false; /** * Mirrors TanStack Query's `isFetching`. * True whenever the query function is executing, including the initial fetch and background refetches. */ @observable isFetching = false; /** * Mirrors TanStack Query's `isRefetching`. * True during a background refetch after the query has already moved past the initial pending state. */ @observable isRefetching = false; /** Mirrors TanStack Query's `isSuccess` derived status flag. */ @observable isSuccess = false; /** Mirrors TanStack Query's `isError` derived status flag. */ @observable isError = false; /** Mirrors TanStack Query's `error` value. Defaults to `null` when no error is present. */ @observable error: TError | null = null; /** * Remaining TanStack Query observer result fields that are not exposed as top-level properties here. * This can include values such as `status`, `fetchStatus`, `failureCount`, and timestamps. */ @observable queryInfo?: Partial>; /** * Mirrors TanStack Query's `refetch`. * Call this to manually rerun the query. */ refetch!: QueryObserverResult['refetch']; storesToRefreshOnMount?: () => (IRefreshOnMountStore | undefined)[] = undefined; /** * The last query data snapshot exposed by this store. * Successful results are deep-cloned before assignment so consumers do not mutate cached data by accident. */ @observable readonly data?: T; clientStore?: QueryClientStore; protected disposables: (() => void)[] = []; private observer?: QueryObserver; @observable private queryOptions: QueryApiOptions = {} as QueryApiOptions; @observable private overrideOptions: Partial> = {}; private invalidateDebounceFn: ((options?: QueryInvalidationOptions) => any) & { cancel(): void; } = Object.assign(() => {}, { cancel: () => {} }); constructor(options?: () => QueryApiOptions) { if (options) { this.reactionOptions = options; } makeObservable(this); } @computed private get combinedOptions(): QueryApiOptions { return { ...this.queryOptions, ...this.overrideOptions }; } /** The current query key derived from options. */ @computed get queryKey(): QueryKey | undefined { return this.combinedOptions?.queryKey; } @computed private get getQueryOptions(): DefaultedQueryObserverOptions { const { disposeQuery, autoInitialize, onError, onSuccess, globalClient, queryFn, ...rest } = this.combinedOptions ?? {}; const defaultedOptions = this.clientStore?.queryClient.defaultQueryOptions< T, unknown, T, T >({ ...rest, queryFn: queryFn ?? (() => Promise.resolve(getClientOptions(globalClient)?.defaultValue ?? ({} as T))), }); return defaultedOptions ?? ({} as DefaultedQueryObserverOptions); } /** * Connects this query to a QueryClientStore and starts observing TanStack Query state. * Call this during store initialization before reading reactive query state. */ @action setup( client?: QueryClientStore, overrides?: () => Partial>, refreshStores?: () => (IRefreshOnMountStore | undefined)[] ) { // set options so setup will run this.queryOptions = this.reactionOptions?.(); if (!isEmpty(this.queryOptions)) { this.disposables.push( reaction(this.reactionOptions, opt => { runInAction(() => { this.queryOptions = opt; }); }) ); } if (overrides && !isEmpty(overrides())) { this.overrideOptions = overrides?.(); this.disposables.push( reaction(overrides, opt => { runInAction(() => { this.overrideOptions = opt; }); }) ); } this.clientStore = shouldShareWindowClient(this.combinedOptions?.globalClient, client) ? new QueryClientStore(this.combinedOptions?.globalClient) : client; this.invalidateDebounceFn = debounce((options?: QueryInvalidationOptions) => { if (this.observer) { this.clientStore?.invalidate(this.combinedOptions.queryKey, options); } }, this.combinedOptions.debounceInvalidateTime ?? 100); this.setupQuery(); if (refreshStores?.()) { this.storesToRefreshOnMount = refreshStores; } } /** Cleans up reactions, query subscriptions, and any owned shared query client. */ dispose() { this.invalidateDebounceFn.cancel(); forEach(this.disposables, disposer => disposer()); this.disposables = []; if (this.combinedOptions.queryKey?.[0] && this.combinedOptions.disposeQuery) { const key = typeof this.combinedOptions.disposeQuery === 'number' ? take(this.combinedOptions.queryKey, this.combinedOptions.disposeQuery) : this.combinedOptions.queryKey; this.clientStore?.remove(key); } if (shouldShareWindowClient(this.combinedOptions?.globalClient, this.clientStore)) { this.clientStore?.dispose(); } this.observer = undefined; } /** * Updates cached data for this query without running the query function. * This is equivalent to calling `queryClient.setQueryData` for the current query key. */ updateQueryData = (data: T): void => { this.clientStore?.queryClient.setQueryData(this.combinedOptions.queryKey, data); }; /** Returns TanStack Query's cached query state for the current query key, if it exists. */ getQueryState = () => this.clientStore?.queryClient.getQueryState(this.combinedOptions.queryKey); /** * Invalidates the current query key so TanStack Query marks it stale and refetches as needed. * Use `dedupe: true` to skip invalidation if the query is already invalidated. * * Note: The `debounce` option is deprecated and will be removed in a future version. Use `dedupe` instead. */ invalidate = (options?: QueryInvalidationOptions) => { if (options?.debounce) { this.invalidateDebounceFn(options); } else { this.clientStore?.invalidate(this.combinedOptions.queryKey, options); } }; /** Cancels any in-flight fetch for the current query key. */ cancel = () => { this.clientStore?.cancel(this.combinedOptions.queryKey); }; /** Calls `refreshOnMount` on every dependent store returned by `storesToRefreshOnMount`. */ refreshStoresOnMount = () => { forEach(this.storesToRefreshOnMount?.(), store => store?.refreshOnMount()); }; /** * Refetches this query on mount when it has already initialized, is currently stale, * and `refetchOnMount` is enabled for the query. */ refreshOnMount = () => { if ( this.initialized && !this.isFetching && (this.getQueryOptions.refetchOnMount ?? true) && this.isStale() ) { this.invalidate(); } }; /** Returns true when TanStack Query currently considers this query stale. */ isStale = () => { const { dataUpdatedAt } = this.getQueryState() ?? {}; const diff = new Date().getTime() - new Date(dataUpdatedAt ?? new Date()).getTime(); return diff >= (this.getQueryOptions.staleTime ?? Infinity); }; @action protected setupQuery = () => { if (!this.observer) { if (isEmpty(this.combinedOptions)) { this.initialized = true; return; } if (!this.combinedOptions.queryKey?.length) { // eslint-disable-next-line no-console console.error('queryKey must be defined'); throw new Error('queryKey must be defined'); } if (!this.clientStore) { // eslint-disable-next-line no-console console.error('QueryClientStore must be defined'); throw new Error('QueryClientStore must be defined'); } this.observer = new QueryObserver( this.clientStore.queryClient, this.getQueryOptions ); // initialize state this.setQueryState(); this.disposables.push( reaction( () => this.getQueryOptions, options => { this.observer?.setOptions(options); } ) ); this.disposables.push(this.observer.subscribe(this.setQueryState)); } }; @action private setQueryState = () => { if (!this.observer) { return; } const options = this.getQueryOptions; const result = this.observer.getOptimisticResult(options); const { data, isLoading, isPending, isRefetching, isFetching, isSuccess, isError, error, refetch, ...other } = !options.notifyOnChangeProps && result ? this.observer.trackResult(result) : result; this.isPending = isPending; this.isLoading = isLoading; this.isSuccess = isSuccess; this.isRefetching = isRefetching; this.isFetching = isFetching; this.isError = isError; this.error = error; this.queryInfo = other; this.refetch = refetch; const hasInvalidation = this.getQueryState()?.isInvalidated; if (!isLoading && !isFetching && !isPending && !hasInvalidation) { if (!isError && isSuccess) { (this.data as any) = cloneDeep(data); this.combinedOptions.onSuccess?.(this.data as T); } else if (isError) { (this.data as any) = cloneDeep(data); this.combinedOptions.onError?.(this.error); if (!this.combinedOptions.onError) { // eslint-disable-next-line no-console console.error(this.error); } } // needs to initialize on both success and error if (!this.initialized && (this.combinedOptions.autoInitialize ?? true)) { this.initialized = true; } } }; private reactionOptions: () => QueryApiOptions = () => ({}) as QueryApiOptions; }