From c472faed04ad70129a0ba5ce027fc079550c4e6f Mon Sep 17 00:00:00 2001 From: Vladimir Filosof <58009491+vladimirfilosof@users.noreply.github.com> Date: Fri, 21 Feb 2025 13:37:35 +0300 Subject: [PATCH] feat: add progressive refetch interval and repeat invalidation (#22) --- src/core/index.ts | 1 + src/core/types/DataManagerOptions.ts | 11 +++ src/core/types/DataManger.ts | 12 ++- src/react-query/ClientDataManager.ts | 89 ++++++++++++++----- src/react-query/hooks/useQueryData.ts | 8 +- src/react-query/hooks/useRefetchInterval.ts | 63 +++++++++++++ src/react-query/impl/infinite/hooks.ts | 45 +++++++++- src/react-query/impl/infinite/types.ts | 24 ++++- src/react-query/impl/infinite/utils.ts | 14 +-- src/react-query/impl/plain/hooks.ts | 41 ++++++++- src/react-query/impl/plain/types.ts | 25 +++++- src/react-query/impl/plain/utils.ts | 10 +-- src/react-query/index.ts | 9 +- src/react-query/{types.ts => types/base.ts} | 4 +- src/react-query/types/index.ts | 2 + src/react-query/types/refetch-interval.ts | 32 +++++++ .../utils/getProgressiveRefetch.ts | 19 ++++ 17 files changed, 357 insertions(+), 52 deletions(-) create mode 100644 src/core/types/DataManagerOptions.ts create mode 100644 src/react-query/hooks/useRefetchInterval.ts rename src/react-query/{types.ts => types/base.ts} (61%) create mode 100644 src/react-query/types/index.ts create mode 100644 src/react-query/types/refetch-interval.ts create mode 100644 src/react-query/utils/getProgressiveRefetch.ts diff --git a/src/core/index.ts b/src/core/index.ts index ec39a57..2621a96 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -17,6 +17,7 @@ export type { } from './types/DataSource'; export type {DataManager} from './types/DataManger'; export type {DataLoaderStatus} from './types/DataLoaderStatus'; +export type {InvalidateRepeatOptions, InvalidateOptions} from './types/DataManagerOptions'; export {idle} from './constants'; diff --git a/src/core/types/DataManagerOptions.ts b/src/core/types/DataManagerOptions.ts new file mode 100644 index 0000000..53603a3 --- /dev/null +++ b/src/core/types/DataManagerOptions.ts @@ -0,0 +1,11 @@ +export interface InvalidateRepeatOptions { + interval: number; + /** + * Number of repeated calls, not counting the first one + */ + count: number; +} + +export interface InvalidateOptions { + repeat?: InvalidateRepeatOptions; +} diff --git a/src/core/types/DataManger.ts b/src/core/types/DataManger.ts index 8ee357a..b0a50ef 100644 --- a/src/core/types/DataManger.ts +++ b/src/core/types/DataManger.ts @@ -1,16 +1,21 @@ +import type {InvalidateOptions} from './DataManagerOptions'; import type {AnyDataSource, DataSourceParams, DataSourceTag} from './DataSource'; export interface DataManager { - invalidateTag(tag: DataSourceTag): Promise; - invalidateTags(tags: DataSourceTag[]): Promise; + invalidateTag(tag: DataSourceTag, invalidateOptions?: InvalidateOptions): Promise; + invalidateTags(tags: DataSourceTag[], invalidateOptions?: InvalidateOptions): Promise; - invalidateSource(dataSource: TDataSource): Promise; + invalidateSource( + dataSource: TDataSource, + invalidateOptions?: InvalidateOptions, + ): Promise; resetSource(dataSource: TDataSource): Promise; invalidateParams( dataSource: TDataSource, params: DataSourceParams, + invalidateOptions?: InvalidateOptions, ): Promise; resetParams( @@ -21,5 +26,6 @@ export interface DataManager { invalidateSourceTags( dataSource: TDataSource, params: DataSourceParams, + invalidateOptions?: InvalidateOptions, ): Promise; } diff --git a/src/react-query/ClientDataManager.ts b/src/react-query/ClientDataManager.ts index c9d644b..457346d 100644 --- a/src/react-query/ClientDataManager.ts +++ b/src/react-query/ClientDataManager.ts @@ -1,4 +1,4 @@ -import type {QueryClientConfig} from '@tanstack/react-query'; +import type {InvalidateQueryFilters, QueryClientConfig} from '@tanstack/react-query'; import {QueryClient} from '@tanstack/react-query'; import { @@ -9,6 +9,7 @@ import { composeFullKey, hasTag, } from '../core'; +import type {InvalidateOptions, InvalidateRepeatOptions} from '../core/types/DataManagerOptions'; export type ClientDataManagerConfig = QueryClientConfig; @@ -32,23 +33,35 @@ export class ClientDataManager implements DataManager { }); } - invalidateTag(tag: DataSourceTag) { - return this.queryClient.invalidateQueries({ - predicate: ({queryKey}) => hasTag(queryKey, tag), - }); + invalidateTag(tag: DataSourceTag, invalidateOptions?: InvalidateOptions) { + return this.invalidateQueries( + { + predicate: ({queryKey}) => hasTag(queryKey, tag), + }, + invalidateOptions, + ); } - invalidateTags(tags: DataSourceTag[]) { - return this.queryClient.invalidateQueries({ - predicate: ({queryKey}) => tags.every((tag) => hasTag(queryKey, tag)), - }); + invalidateTags(tags: DataSourceTag[], invalidateOptions?: InvalidateOptions) { + return this.invalidateQueries( + { + predicate: ({queryKey}) => tags.every((tag) => hasTag(queryKey, tag)), + }, + invalidateOptions, + ); } - invalidateSource(dataSource: TDataSource) { - return this.queryClient.invalidateQueries({ - // First element is a data source name - queryKey: [dataSource.name], - }); + invalidateSource( + dataSource: TDataSource, + invalidateOptions?: InvalidateOptions, + ) { + return this.invalidateQueries( + { + // First element is a data source name + queryKey: [dataSource.name], + }, + invalidateOptions, + ); } resetSource(dataSource: TDataSource) { @@ -61,11 +74,15 @@ export class ClientDataManager implements DataManager { invalidateParams( dataSource: TDataSource, params: DataSourceParams, + invalidateOptions?: InvalidateOptions, ) { - return this.queryClient.invalidateQueries({ - queryKey: composeFullKey(dataSource, params), - exact: true, - }); + return this.invalidateQueries( + { + queryKey: composeFullKey(dataSource, params), + exact: true, + }, + invalidateOptions, + ); } resetParams( @@ -81,10 +98,38 @@ export class ClientDataManager implements DataManager { invalidateSourceTags( dataSource: TDataSource, params: DataSourceParams, + invalidateOptions?: InvalidateOptions, ) { - return this.queryClient.invalidateQueries({ - // Last element is a full key - queryKey: composeFullKey(dataSource, params).slice(0, -1), - }); + return this.invalidateQueries( + { + // Last element is a full key + queryKey: composeFullKey(dataSource, params).slice(0, -1), + }, + invalidateOptions, + ); + } + + private invalidateQueries( + filters: InvalidateQueryFilters, + invalidateOptions?: InvalidateOptions, + ) { + const {repeat} = invalidateOptions || {}; + + const invalidate = () => this.queryClient.invalidateQueries(filters); + + this.repeatInvalidate(invalidate, repeat); + + return invalidate(); + } + + private repeatInvalidate(invalidate: () => Promise, repeat?: InvalidateRepeatOptions) { + if (!repeat) { + return; + } + const {interval, count} = repeat; + + for (let i = 1; i <= count; i++) { + setTimeout(invalidate, interval * i); + } } } diff --git a/src/react-query/hooks/useQueryData.ts b/src/react-query/hooks/useQueryData.ts index c5cb44c..06fd68b 100644 --- a/src/react-query/hooks/useQueryData.ts +++ b/src/react-query/hooks/useQueryData.ts @@ -2,6 +2,7 @@ import type {DataSourceOptions, DataSourceParams, DataSourceState} from '../../c import {useInfiniteQueryData} from '../impl/infinite/hooks'; import type {AnyInfiniteQueryDataSource} from '../impl/infinite/types'; import {usePlainQueryData} from '../impl/plain/hooks'; +import type {AnyPlainQueryDataSource} from '../impl/plain/types'; import type {AnyQueryDataSource} from '../types'; import {notReachable} from '../utils/notReachable'; @@ -20,7 +21,12 @@ export const useQueryData = ( // Do not change data source type in the same hook call if (type === 'plain') { // eslint-disable-next-line react-hooks/rules-of-hooks - state = usePlainQueryData(context, dataSource, params, options); + state = usePlainQueryData( + context, + dataSource, + params, + options as Partial> | undefined, + ); } else if (type === 'infinite') { // eslint-disable-next-line react-hooks/rules-of-hooks state = useInfiniteQueryData( diff --git a/src/react-query/hooks/useRefetchInterval.ts b/src/react-query/hooks/useRefetchInterval.ts new file mode 100644 index 0000000..acc0223 --- /dev/null +++ b/src/react-query/hooks/useRefetchInterval.ts @@ -0,0 +1,63 @@ +import React from 'react'; + +import type {Query, QueryFunction, QueryFunctionContext, SkipToken} from '@tanstack/react-query'; + +import type {DataSourceError, DataSourceKey, DataSourceResponse} from '../../core'; +import type {AnyQueryDataSource, RefetchInterval} from '../types'; + +export const useRefetchInterval = ( + refetchIntervalOption?: RefetchInterval< + DataSourceResponse, + DataSourceError, + TQueryData, + DataSourceKey + >, + queryFnOption?: + | QueryFunction, DataSourceKey, TPageParams> + | SkipToken, +): { + refetchInterval?: + | number + | false + | (( + query: Query< + DataSourceResponse, + DataSourceError, + TQueryData, + DataSourceKey + >, + ) => number | false | undefined); + queryFn?: + | QueryFunction, DataSourceKey, TPageParams> + | SkipToken; +} => { + const count = React.useRef(0); + + const queryFn = React.useMemo(() => { + if (typeof queryFnOption === 'function') { + return (context: QueryFunctionContext) => { + count.current++; + return queryFnOption(context); + }; + } + return undefined; + }, [queryFnOption]); + + const refetchInterval = React.useMemo(() => { + if (typeof refetchIntervalOption === 'function') { + return ( + query: Query< + DataSourceResponse, + DataSourceError, + TQueryData, + DataSourceKey + >, + ) => { + return refetchIntervalOption(query, count.current); + }; + } + return refetchIntervalOption; + }, [refetchIntervalOption]); + + return {refetchInterval, queryFn}; +}; diff --git a/src/react-query/impl/infinite/hooks.ts b/src/react-query/impl/infinite/hooks.ts index 9b06fb0..5e4c4b8 100644 --- a/src/react-query/impl/infinite/hooks.ts +++ b/src/react-query/impl/infinite/hooks.ts @@ -1,16 +1,26 @@ import {useMemo} from 'react'; import {useInfiniteQuery} from '@tanstack/react-query'; +import type {InfiniteData, InfiniteQueryObserverOptions} from '@tanstack/react-query'; import type { DataSourceContext, + DataSourceData, + DataSourceError, + DataSourceKey, DataSourceOptions, DataSourceParams, + DataSourceResponse, DataSourceState, } from '../../../core'; +import {useRefetchInterval} from '../../hooks/useRefetchInterval'; import {normalizeStatus} from '../../utils/normalizeStatus'; -import type {AnyInfiniteQueryDataSource} from './types'; +import type { + AnyInfiniteQueryDataSource, + AnyPageParam, + InfiniteQueryObserverExtendedOptions, +} from './types'; import {composeOptions} from './utils'; export const useInfiniteQueryData = ( @@ -20,7 +30,10 @@ export const useInfiniteQueryData = >, ): DataSourceState => { const composedOptions = composeOptions(context, dataSource, params, options); - const result = useInfiniteQuery(composedOptions); + + const extendedOptions = useInfiniteQueryDataOptions(composedOptions); + + const result = useInfiniteQuery(extendedOptions); const transformedData = useMemo['data']>( () => result.data?.pages.flat(1) ?? [], @@ -35,3 +48,31 @@ export const useInfiniteQueryData = ; }; + +export function useInfiniteQueryDataOptions( + composedOptions: InfiniteQueryObserverExtendedOptions< + DataSourceResponse, + DataSourceError, + InfiniteData, AnyPageParam>, + DataSourceResponse, + DataSourceKey, + AnyPageParam + >, +): InfiniteQueryObserverOptions< + DataSourceResponse, + DataSourceError, + InfiniteData, AnyPageParam>, + DataSourceResponse, + DataSourceKey, + AnyPageParam +> { + const { + refetchInterval: refetchIntervalOption, + queryFn: queryFnOption, + ...restOptions + } = composedOptions || {}; + + const {refetchInterval, queryFn} = useRefetchInterval(refetchIntervalOption, queryFnOption); + + return {...restOptions, refetchInterval, queryFn}; +} diff --git a/src/react-query/impl/infinite/types.ts b/src/react-query/impl/infinite/types.ts index a0a9eab..a287c5e 100644 --- a/src/react-query/impl/infinite/types.ts +++ b/src/react-query/impl/infinite/types.ts @@ -1,13 +1,33 @@ import type { + DefaultError, InfiniteData, - InfiniteQueryObserverOptions, InfiniteQueryObserverResult, + InfiniteQueryPageParamsOptions, QueryFunctionContext, + QueryKey, } from '@tanstack/react-query'; import type {Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; import type {QueryDataSourceContext} from '../../types'; +import type {QueryObserverExtendedOptions} from '../plain/types'; + +export interface InfiniteQueryObserverExtendedOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, +> extends QueryObserverExtendedOptions< + TQueryFnData, + TError, + TData, + InfiniteData, + TQueryKey, + TPageParam + >, + InfiniteQueryPageParamsOptions {} export type InfiniteQueryDataSource = DataSource< QueryDataSourceContext, @@ -16,7 +36,7 @@ export type InfiniteQueryDataSource TResponse, TData, TError, - InfiniteQueryObserverOptions< + InfiniteQueryObserverExtendedOptions< TResponse, TError, InfiniteData, Partial>, diff --git a/src/react-query/impl/infinite/utils.ts b/src/react-query/impl/infinite/utils.ts index 3f3a1a4..f0b0163 100644 --- a/src/react-query/impl/infinite/utils.ts +++ b/src/react-query/impl/infinite/utils.ts @@ -1,9 +1,5 @@ import {skipToken} from '@tanstack/react-query'; -import type { - InfiniteData, - InfiniteQueryObserverOptions, - QueryFunctionContext, -} from '@tanstack/react-query'; +import type {InfiniteData, QueryFunctionContext} from '@tanstack/react-query'; import {composeFullKey, idle} from '../../../core'; import type { @@ -16,7 +12,11 @@ import type { DataSourceResponse, } from '../../../core'; -import type {AnyInfiniteQueryDataSource, AnyPageParam} from './types'; +import type { + AnyInfiniteQueryDataSource, + AnyPageParam, + InfiniteQueryObserverExtendedOptions, +} from './types'; const EMPTY_OBJECT = {}; @@ -25,7 +25,7 @@ export const composeOptions = ( dataSource: TDataSource, params: DataSourceParams, options?: Partial>, -): InfiniteQueryObserverOptions< +): InfiniteQueryObserverExtendedOptions< DataSourceResponse, DataSourceError, InfiniteData, AnyPageParam>, diff --git a/src/react-query/impl/plain/hooks.ts b/src/react-query/impl/plain/hooks.ts index 20a8ea8..dae7c1e 100644 --- a/src/react-query/impl/plain/hooks.ts +++ b/src/react-query/impl/plain/hooks.ts @@ -1,14 +1,20 @@ -import {useQuery} from '@tanstack/react-query'; +import {type QueryObserverOptions, useQuery} from '@tanstack/react-query'; import type { DataSourceContext, + DataSourceData, + DataSourceError, + DataSourceKey, DataSourceOptions, DataSourceParams, + DataSourceResponse, DataSourceState, } from '../../../core'; +import {useRefetchInterval} from '../../hooks/useRefetchInterval'; +import type {AnyQueryDataSource} from '../../types'; import {normalizeStatus} from '../../utils/normalizeStatus'; -import type {AnyPlainQueryDataSource} from './types'; +import type {AnyPlainQueryDataSource, QueryObserverExtendedOptions} from './types'; import {composeOptions} from './utils'; export const usePlainQueryData = ( @@ -18,7 +24,10 @@ export const usePlainQueryData = ( options?: Partial>, ): DataSourceState => { const composedOptions = composeOptions(context, dataSource, params, options); - const result = useQuery(composedOptions); + + const extendedOptions = useQueryDataOptions(composedOptions); + + const result = useQuery(extendedOptions); return { ...result, @@ -26,3 +35,29 @@ export const usePlainQueryData = ( originalStatus: result.status, } as DataSourceState; }; + +export function useQueryDataOptions( + composedOptions: QueryObserverExtendedOptions< + DataSourceResponse, + DataSourceError, + DataSourceData, + DataSourceResponse, + DataSourceKey + >, +): QueryObserverOptions< + DataSourceResponse, + DataSourceError, + DataSourceData, + DataSourceResponse, + DataSourceKey +> { + const { + refetchInterval: refetchIntervalOption, + queryFn: queryFnOption, + ...restOptions + } = composedOptions || {}; + + const {refetchInterval, queryFn} = useRefetchInterval(refetchIntervalOption, queryFnOption); + + return {...restOptions, refetchInterval, queryFn}; +} diff --git a/src/react-query/impl/plain/types.ts b/src/react-query/impl/plain/types.ts index 2363614..06e2734 100644 --- a/src/react-query/impl/plain/types.ts +++ b/src/react-query/impl/plain/types.ts @@ -1,12 +1,27 @@ import type { + DefaultError, QueryFunctionContext, + QueryKey, QueryObserverOptions, QueryObserverResult, } from '@tanstack/react-query'; import type {Overwrite} from 'utility-types'; import type {ActualData, DataLoaderStatus, DataSource, DataSourceKey} from '../../../core'; -import type {QueryDataSourceContext} from '../../types'; +import type {QueryDataExtendedOptions, QueryDataSourceContext} from '../../types'; + +export type QueryObserverExtendedOptions< + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + TPageParam = never, +> = Omit< + QueryObserverOptions, + 'refetchInterval' +> & + QueryDataExtendedOptions; export type PlainQueryDataSource = DataSource< QueryDataSourceContext, @@ -15,7 +30,13 @@ export type PlainQueryDataSource = TResponse, TData, TError, - QueryObserverOptions, TResponse, DataSourceKey>, + QueryObserverExtendedOptions< + TResponse, + TError, + ActualData, + TResponse, + DataSourceKey + >, ResultWrapper< QueryObserverResult, TError>, TResponse, diff --git a/src/react-query/impl/plain/utils.ts b/src/react-query/impl/plain/utils.ts index 04ff7c6..a5c3b4e 100644 --- a/src/react-query/impl/plain/utils.ts +++ b/src/react-query/impl/plain/utils.ts @@ -1,8 +1,4 @@ -import { - type QueryFunctionContext, - type QueryObserverOptions, - skipToken, -} from '@tanstack/react-query'; +import {type QueryFunctionContext, skipToken} from '@tanstack/react-query'; import {composeFullKey, idle} from '../../../core'; import type { @@ -15,14 +11,14 @@ import type { DataSourceResponse, } from '../../../core'; -import type {AnyPlainQueryDataSource} from './types'; +import type {AnyPlainQueryDataSource, QueryObserverExtendedOptions} from './types'; export const composeOptions = ( context: DataSourceContext, dataSource: TDataSource, params: DataSourceParams, options?: Partial>, -): QueryObserverOptions< +): QueryObserverExtendedOptions< DataSourceResponse, DataSourceError, DataSourceData, diff --git a/src/react-query/index.ts b/src/react-query/index.ts index 9a1818c..cc52ed1 100644 --- a/src/react-query/index.ts +++ b/src/react-query/index.ts @@ -1,4 +1,10 @@ -export type {QueryDataSourceContext, AnyQueryDataSource} from './types'; +export type { + QueryDataSourceContext, + AnyQueryDataSource, + ProgressiveRefetchInterval, + RefetchInterval, + RefetchIntervalFunction, +} from './types'; export {useQueryContext} from './hooks/useQueryContext'; export {useQueryData} from './hooks/useQueryData'; @@ -15,6 +21,7 @@ export {makePlainQueryDataSource} from './impl/plain/factory'; export {composeOptions as composePlainQueryOptions} from './impl/plain/utils'; export {normalizeStatus} from './utils/normalizeStatus'; +export {getProgressiveRefetch} from './utils/getProgressiveRefetch'; export type {ClientDataManagerConfig} from './ClientDataManager'; export {ClientDataManager} from './ClientDataManager'; diff --git a/src/react-query/types.ts b/src/react-query/types/base.ts similarity index 61% rename from src/react-query/types.ts rename to src/react-query/types/base.ts index fd257b3..27f2bc5 100644 --- a/src/react-query/types.ts +++ b/src/react-query/types/base.ts @@ -1,7 +1,7 @@ import type {QueryClient} from '@tanstack/react-query'; -import type {AnyInfiniteQueryDataSource} from './impl/infinite/types'; -import type {AnyPlainQueryDataSource} from './impl/plain/types'; +import type {AnyInfiniteQueryDataSource} from '../impl/infinite/types'; +import type {AnyPlainQueryDataSource} from '../impl/plain/types'; export interface QueryDataSourceContext { queryClient: QueryClient; diff --git a/src/react-query/types/index.ts b/src/react-query/types/index.ts new file mode 100644 index 0000000..c660270 --- /dev/null +++ b/src/react-query/types/index.ts @@ -0,0 +1,2 @@ +export * from './base'; +export * from './refetch-interval'; diff --git a/src/react-query/types/refetch-interval.ts b/src/react-query/types/refetch-interval.ts new file mode 100644 index 0000000..de06d70 --- /dev/null +++ b/src/react-query/types/refetch-interval.ts @@ -0,0 +1,32 @@ +import type {DefaultError, Query, QueryKey} from '@tanstack/react-query'; + +export type RefetchIntervalFunction< + TQueryFnData = unknown, + TError = DefaultError, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = ( + query: Query, + count: number, +) => number | false | undefined; + +export type RefetchInterval< + TQueryFnData = unknown, + TError = DefaultError, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> = number | false | RefetchIntervalFunction; + +export interface ProgressiveRefetchInterval { + minInterval: number; + maxInterval: number; +} + +export interface QueryDataExtendedOptions< + TQueryFnData = unknown, + TError = DefaultError, + TQueryData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +> { + refetchInterval?: RefetchInterval; +} diff --git a/src/react-query/utils/getProgressiveRefetch.ts b/src/react-query/utils/getProgressiveRefetch.ts new file mode 100644 index 0000000..88b5f73 --- /dev/null +++ b/src/react-query/utils/getProgressiveRefetch.ts @@ -0,0 +1,19 @@ +import type {DefaultError, QueryKey} from '@tanstack/react-query'; + +import type {ProgressiveRefetchInterval, RefetchIntervalFunction} from '../types'; + +const BASE = 2; + +export const getProgressiveRefetch = < + TQueryFnData = unknown, + TError = DefaultError, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>({ + minInterval, + maxInterval, +}: ProgressiveRefetchInterval): RefetchIntervalFunction => { + return (_, queryRefetchCount) => { + return Math.min(minInterval * BASE ** queryRefetchCount, maxInterval); + }; +};