import { inject, injectable, optional, Store } from '@servicetitan/react-ioc'; import type { QueryClientConfig, FetchQueryOptions, QueryKey } from '@tanstack/query-core'; import { QueryClient } from '@tanstack/query-core'; import { AxiosError } from 'axios'; import cloneDeep from 'lodash/cloneDeep'; import forEach from 'lodash/forEach'; import isEmpty from 'lodash/isEmpty'; import type { ClientStoreClientOptions } from '../types/client-store-client-options'; import type { QueryInvalidationOptions } from '../types/query-invalidation-options'; import type { GlobalClientOptions } from './client-helpers'; import { getClientType, getTypeName, isArrayofArrays } from './client-helpers'; declare global { export interface Window { queryClients?: Map; pageQueryClientCount?: number; } } const GlobalOptionsSymbol = Symbol('GlobalClientOptions'); const QueryConfigOptionsSymbol = Symbol('QueryConfigOptions'); /** * Manages the TanStack QueryClient instance and provides methods for query manipulation. * Automatically provided when using getQueryClient() helper or can be injected directly. * * @remarks * This store creates and manages a QueryClient with sensible defaults: * - 10-minute stale time * - No refetch on window focus or reconnect * - 3 retry attempts with exponential backoff * - 5-minute garbage collection time * * @example * ```typescript * @injectable() * export class MyStore extends Store { * @inject(QueryClientStore) private queryClient?: QueryClientStore; * * async fetchData() { * return await this.queryClient?.fetchQuery({ * queryKey: ['myapp', 'data'], * queryFn: async () => await api.getData(), * }); * } * } * ``` */ @injectable() export class QueryClientStore extends Store { /** The underlying TanStack QueryClient instance */ queryClient: QueryClient; /** Configuration for global (page/app) client sharing */ globalClientOptions?: GlobalClientOptions; /** * When true, QueryApi/MutationApi skip `window.queryClients` even if `globalClient` is set. * ContainerBuilder sets this on its test client. Production leaves it unset so window sharing still applies. */ isolateFromWindowClients?: boolean; constructor( @inject(GlobalOptionsSymbol) @optional() globalOptions?: GlobalClientOptions, @inject(QueryConfigOptionsSymbol) @optional() queryConfig?: QueryClientConfig ) { super(); this.globalClientOptions = globalOptions; this.queryClient = this.#getClient(queryConfig); } dispose = (): void => { this.#removeSharedQueryClient(this.globalClientOptions); }; /** * Manually fetches a query without subscribing to it. * Respects caching and stale time settings. * * @template T - The type of data returned by the query * @template TError - The error type * @param queryConfig - Query configuration (queryKey, queryFn, etc.) * @param options - Optional client options (use appClient for app-level cache) * @returns Promise that resolves with a deep clone of the query data * * @example * ```typescript * const report = await queryClientStore.fetchQuery({ * queryKey: ['reporting', 'report', reportId], * queryFn: async () => (await api.getReport(reportId)).data, * }); * ``` */ fetchQuery = async ( queryConfig: FetchQueryOptions, options?: ClientStoreClientOptions ) => { const targetClient = this.#getTargetClient(options); const data = await targetClient.fetchQuery(queryConfig); return cloneDeep(data); }; /** * Invalidates queries matching the provided key(s), causing them to refetch. * Invalidates across all query clients (current store client and all shared clients). * Can invalidate multiple queries at once. * * @param keys - Single query key or array of query keys to invalidate * @param options - Optional invalidation options * @param options.dedupe - Skip invalidation on a client if query is already invalidated there * @param options.invalidationKey - Use a different key for invalidation than the query key * * @example * ```typescript * // Invalidate a single query * queryClientStore.invalidate(['scheduling', 'jobs']); * * // Invalidate multiple queries * queryClientStore.invalidate([['scheduling', 'jobs'], ['scheduling', 'users']]); * * // Invalidate with deduplication * queryClientStore.invalidate(['scheduling', 'jobs'], { dedupe: true }); * ``` */ invalidate = ( keys?: QueryKey | QueryKey[], options?: Omit ) => { if (isEmpty(keys)) { return; } const clients = this.#getAllClients(); const keyItems = (isArrayofArrays(keys) ? keys : [keys]) as QueryKey[]; forEach(keyItems, key => { forEach(clients, client => { if (!options?.dedupe || client.getQueryState(key)?.isInvalidated !== true) { client.invalidateQueries({ queryKey: options?.invalidationKey ?? key }); } }); }); }; /** * Cancels outbound requests for queries matching the provided key(s). * * @param keys - Single query key or array of query keys to cancel * @param options - Optional client options (use appClient for app-level cache) * * @example * ```typescript * // Cancel a single query * queryClientStore.cancel(['scheduling', 'jobs']); * * // Cancel multiple queries * queryClientStore.cancel([['scheduling', 'jobs'], ['scheduling', 'users']]); * ``` */ cancel = (keys?: QueryKey | QueryKey[], options?: ClientStoreClientOptions) => { if (isEmpty(keys)) { return; } const targetClient = this.#getTargetClient(options); const keyItems = (isArrayofArrays(keys) ? keys : [keys]) as QueryKey[]; forEach(keyItems, key => targetClient.cancelQueries({ queryKey: key })); }; /** * Removes queries from the cache completely. * Unlike invalidate, this does not trigger a refetch. * * @param keys - Single query key or array of query keys to remove * * @example * ```typescript * // Remove a single query * queryClientStore.remove(['old', 'data']); * * // Remove multiple queries * queryClientStore.remove([['old', 'data'], ['stale', 'data']]); * ``` */ remove = (keys?: QueryKey | QueryKey[]) => { if (isEmpty(keys)) { return; } const keyItems = (isArrayofArrays(keys) ? keys : [keys]) as QueryKey[]; forEach(keyItems, key => this.queryClient.removeQueries({ queryKey: key })); }; #getClient(queryConfig?: QueryClientConfig): QueryClient { let client = this.#getSharedQueryClient(this.globalClientOptions); if (client) { this.#addPageQueryClientCount(this.globalClientOptions); } else { client = this.#buildQueryClient(queryConfig); } return client; } #getTargetClient(options?: ClientStoreClientOptions) { let targetClient: QueryClient = this.queryClient; if (options?.appClient) { targetClient = this.#getSharedQueryClient('app') ?? this.queryClient; } return targetClient; } #getAllClients(): QueryClient[] { const clients = new Set([this.queryClient]); window.queryClients?.forEach(client => clients.add(client)); return Array.from(clients); } #getSharedQueryClient(options?: GlobalClientOptions) { const clientType = getClientType(options); if (!clientType) { return undefined; } const clientName = getTypeName(clientType); let client = window.queryClients?.get(clientName); if (!client) { client = this.#buildQueryClient(); window.queryClients = window.queryClients ?? new Map(); window.queryClients.set(clientName, client); } return client; } #addPageQueryClientCount(options?: GlobalClientOptions) { const clientType = getClientType(options); if (clientType === 'page') { window.pageQueryClientCount = (window.pageQueryClientCount ?? 0) + 1; } } #removeSharedQueryClient(options?: GlobalClientOptions) { const clientType = getClientType(options); if (clientType && clientType !== 'app') { const pageClientCount = window.pageQueryClientCount ?? 0; if (pageClientCount < 2) { window.pageQueryClientCount = 0; window.queryClients?.forEach((_, key) => { if (!key.startsWith('app')) { window.queryClients?.delete(key); } }); } else { window.pageQueryClientCount = pageClientCount - 1; } } } #buildQueryClient(config?: QueryClientConfig) { return new QueryClient( config ?? { defaultOptions: { queries: { staleTime: 1000 * 60 * 10, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: 3, }, }, } ); } }