New source found from dndbeyond.com
This commit is contained in:
+86
@@ -0,0 +1,86 @@
|
||||
import { Subscribable } from './subscribable'
|
||||
import { isServer } from './utils'
|
||||
|
||||
type Listener = (focused: boolean) => void
|
||||
|
||||
type SetupFn = (
|
||||
setFocused: (focused?: boolean) => void,
|
||||
) => (() => void) | undefined
|
||||
|
||||
export class FocusManager extends Subscribable<Listener> {
|
||||
#focused?: boolean
|
||||
#cleanup?: () => void
|
||||
|
||||
#setup: SetupFn
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.#setup = (onFocus) => {
|
||||
// addEventListener does not exist in React Native, but window does
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!isServer && window.addEventListener) {
|
||||
const listener = () => onFocus()
|
||||
// Listen to visibilitychange
|
||||
window.addEventListener('visibilitychange', listener, false)
|
||||
|
||||
return () => {
|
||||
// Be sure to unsubscribe if a new handler is set
|
||||
window.removeEventListener('visibilitychange', listener)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
protected onSubscribe(): void {
|
||||
if (!this.#cleanup) {
|
||||
this.setEventListener(this.#setup)
|
||||
}
|
||||
}
|
||||
|
||||
protected onUnsubscribe() {
|
||||
if (!this.hasListeners()) {
|
||||
this.#cleanup?.()
|
||||
this.#cleanup = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setEventListener(setup: SetupFn): void {
|
||||
this.#setup = setup
|
||||
this.#cleanup?.()
|
||||
this.#cleanup = setup((focused) => {
|
||||
if (typeof focused === 'boolean') {
|
||||
this.setFocused(focused)
|
||||
} else {
|
||||
this.onFocus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setFocused(focused?: boolean): void {
|
||||
const changed = this.#focused !== focused
|
||||
if (changed) {
|
||||
this.#focused = focused
|
||||
this.onFocus()
|
||||
}
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
const isFocused = this.isFocused()
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(isFocused)
|
||||
})
|
||||
}
|
||||
|
||||
isFocused(): boolean {
|
||||
if (typeof this.#focused === 'boolean') {
|
||||
return this.#focused
|
||||
}
|
||||
|
||||
// document global can be unavailable in react native
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return globalThis.document?.visibilityState !== 'hidden'
|
||||
}
|
||||
}
|
||||
|
||||
export const focusManager = new FocusManager()
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
addConsumeAwareSignal,
|
||||
addToEnd,
|
||||
addToStart,
|
||||
ensureQueryFn,
|
||||
} from './utils'
|
||||
import type { QueryBehavior } from './query'
|
||||
import type {
|
||||
InfiniteData,
|
||||
InfiniteQueryPageParamsOptions,
|
||||
OmitKeyof,
|
||||
QueryFunctionContext,
|
||||
QueryKey,
|
||||
} from './types'
|
||||
|
||||
export function infiniteQueryBehavior<TQueryFnData, TError, TData, TPageParam>(
|
||||
pages?: number,
|
||||
): QueryBehavior<TQueryFnData, TError, InfiniteData<TData, TPageParam>> {
|
||||
return {
|
||||
onFetch: (context, query) => {
|
||||
const options = context.options as InfiniteQueryPageParamsOptions<TData>
|
||||
const direction = context.fetchOptions?.meta?.fetchMore?.direction
|
||||
const oldPages = context.state.data?.pages || []
|
||||
const oldPageParams = context.state.data?.pageParams || []
|
||||
let result: InfiniteData<unknown> = { pages: [], pageParams: [] }
|
||||
let currentPage = 0
|
||||
|
||||
const fetchFn = async () => {
|
||||
let cancelled = false
|
||||
const addSignalProperty = (object: unknown) => {
|
||||
addConsumeAwareSignal(
|
||||
object,
|
||||
() => context.signal,
|
||||
() => (cancelled = true),
|
||||
)
|
||||
}
|
||||
|
||||
const queryFn = ensureQueryFn(context.options, context.fetchOptions)
|
||||
|
||||
// Create function to fetch a page
|
||||
const fetchPage = async (
|
||||
data: InfiniteData<unknown>,
|
||||
param: unknown,
|
||||
previous?: boolean,
|
||||
): Promise<InfiniteData<unknown>> => {
|
||||
if (cancelled) {
|
||||
return Promise.reject()
|
||||
}
|
||||
|
||||
if (param == null && data.pages.length) {
|
||||
return Promise.resolve(data)
|
||||
}
|
||||
|
||||
const createQueryFnContext = () => {
|
||||
const queryFnContext: OmitKeyof<
|
||||
QueryFunctionContext<QueryKey, unknown>,
|
||||
'signal'
|
||||
> = {
|
||||
client: context.client,
|
||||
queryKey: context.queryKey,
|
||||
pageParam: param,
|
||||
direction: previous ? 'backward' : 'forward',
|
||||
meta: context.options.meta,
|
||||
}
|
||||
addSignalProperty(queryFnContext)
|
||||
return queryFnContext as QueryFunctionContext<QueryKey, unknown>
|
||||
}
|
||||
|
||||
const queryFnContext = createQueryFnContext()
|
||||
|
||||
const page = await queryFn(queryFnContext)
|
||||
|
||||
const { maxPages } = context.options
|
||||
const addTo = previous ? addToStart : addToEnd
|
||||
|
||||
return {
|
||||
pages: addTo(data.pages, page, maxPages),
|
||||
pageParams: addTo(data.pageParams, param, maxPages),
|
||||
}
|
||||
}
|
||||
|
||||
// fetch next / previous page?
|
||||
if (direction && oldPages.length) {
|
||||
const previous = direction === 'backward'
|
||||
const pageParamFn = previous ? getPreviousPageParam : getNextPageParam
|
||||
const oldData = {
|
||||
pages: oldPages,
|
||||
pageParams: oldPageParams,
|
||||
}
|
||||
const param = pageParamFn(options, oldData)
|
||||
|
||||
result = await fetchPage(oldData, param, previous)
|
||||
} else {
|
||||
const remainingPages = pages ?? oldPages.length
|
||||
|
||||
// Fetch all pages
|
||||
do {
|
||||
const param =
|
||||
currentPage === 0
|
||||
? (oldPageParams[0] ?? options.initialPageParam)
|
||||
: getNextPageParam(options, result)
|
||||
if (currentPage > 0 && param == null) {
|
||||
break
|
||||
}
|
||||
result = await fetchPage(result, param)
|
||||
currentPage++
|
||||
} while (currentPage < remainingPages)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
if (context.options.persister) {
|
||||
context.fetchFn = () => {
|
||||
return context.options.persister?.(
|
||||
fetchFn as any,
|
||||
{
|
||||
client: context.client,
|
||||
queryKey: context.queryKey,
|
||||
meta: context.options.meta,
|
||||
signal: context.signal,
|
||||
},
|
||||
query,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
context.fetchFn = fetchFn
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getNextPageParam(
|
||||
options: InfiniteQueryPageParamsOptions<any>,
|
||||
{ pages, pageParams }: InfiniteData<unknown>,
|
||||
): unknown | undefined {
|
||||
const lastIndex = pages.length - 1
|
||||
return pages.length > 0
|
||||
? options.getNextPageParam(
|
||||
pages[lastIndex],
|
||||
pages,
|
||||
pageParams[lastIndex],
|
||||
pageParams,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function getPreviousPageParam(
|
||||
options: InfiniteQueryPageParamsOptions<any>,
|
||||
{ pages, pageParams }: InfiniteData<unknown>,
|
||||
): unknown | undefined {
|
||||
return pages.length > 0
|
||||
? options.getPreviousPageParam?.(pages[0], pages, pageParams[0], pageParams)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there is a next page.
|
||||
*/
|
||||
export function hasNextPage(
|
||||
options: InfiniteQueryPageParamsOptions<any, any>,
|
||||
data?: InfiniteData<unknown>,
|
||||
): boolean {
|
||||
if (!data) return false
|
||||
return getNextPageParam(options, data) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there is a previous page.
|
||||
*/
|
||||
export function hasPreviousPage(
|
||||
options: InfiniteQueryPageParamsOptions<any, any>,
|
||||
data?: InfiniteData<unknown>,
|
||||
): boolean {
|
||||
if (!data || !options.getPreviousPageParam) return false
|
||||
return getPreviousPageParam(options, data) != null
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
import { notifyManager } from './notifyManager'
|
||||
import { Removable } from './removable'
|
||||
import { createRetryer } from './retryer'
|
||||
import type {
|
||||
DefaultError,
|
||||
MutationFunctionContext,
|
||||
MutationMeta,
|
||||
MutationOptions,
|
||||
MutationStatus,
|
||||
} from './types'
|
||||
import type { MutationCache } from './mutationCache'
|
||||
import type { MutationObserver } from './mutationObserver'
|
||||
import type { Retryer } from './retryer'
|
||||
import type { QueryClient } from './queryClient'
|
||||
|
||||
// TYPES
|
||||
|
||||
interface MutationConfig<TData, TError, TVariables, TOnMutateResult> {
|
||||
client: QueryClient
|
||||
mutationId: number
|
||||
mutationCache: MutationCache
|
||||
options: MutationOptions<TData, TError, TVariables, TOnMutateResult>
|
||||
state?: MutationState<TData, TError, TVariables, TOnMutateResult>
|
||||
}
|
||||
|
||||
export interface MutationState<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = unknown,
|
||||
TOnMutateResult = unknown,
|
||||
> {
|
||||
context: TOnMutateResult | undefined
|
||||
data: TData | undefined
|
||||
error: TError | null
|
||||
failureCount: number
|
||||
failureReason: TError | null
|
||||
isPaused: boolean
|
||||
status: MutationStatus
|
||||
variables: TVariables | undefined
|
||||
submittedAt: number
|
||||
}
|
||||
|
||||
interface FailedAction<TError> {
|
||||
type: 'failed'
|
||||
failureCount: number
|
||||
error: TError | null
|
||||
}
|
||||
|
||||
interface PendingAction<TVariables, TOnMutateResult> {
|
||||
type: 'pending'
|
||||
isPaused: boolean
|
||||
variables?: TVariables
|
||||
context?: TOnMutateResult
|
||||
}
|
||||
|
||||
interface SuccessAction<TData> {
|
||||
type: 'success'
|
||||
data: TData
|
||||
}
|
||||
|
||||
interface ErrorAction<TError> {
|
||||
type: 'error'
|
||||
error: TError
|
||||
}
|
||||
|
||||
interface PauseAction {
|
||||
type: 'pause'
|
||||
}
|
||||
|
||||
interface ContinueAction {
|
||||
type: 'continue'
|
||||
}
|
||||
|
||||
export type Action<TData, TError, TVariables, TOnMutateResult> =
|
||||
| ContinueAction
|
||||
| ErrorAction<TError>
|
||||
| FailedAction<TError>
|
||||
| PendingAction<TVariables, TOnMutateResult>
|
||||
| PauseAction
|
||||
| SuccessAction<TData>
|
||||
|
||||
// CLASS
|
||||
|
||||
export class Mutation<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = unknown,
|
||||
TOnMutateResult = unknown,
|
||||
> extends Removable {
|
||||
state: MutationState<TData, TError, TVariables, TOnMutateResult>
|
||||
options!: MutationOptions<TData, TError, TVariables, TOnMutateResult>
|
||||
readonly mutationId: number
|
||||
|
||||
#client: QueryClient
|
||||
#observers: Array<
|
||||
MutationObserver<TData, TError, TVariables, TOnMutateResult>
|
||||
>
|
||||
#mutationCache: MutationCache
|
||||
#retryer?: Retryer<TData>
|
||||
|
||||
constructor(
|
||||
config: MutationConfig<TData, TError, TVariables, TOnMutateResult>,
|
||||
) {
|
||||
super()
|
||||
|
||||
this.#client = config.client
|
||||
this.mutationId = config.mutationId
|
||||
this.#mutationCache = config.mutationCache
|
||||
this.#observers = []
|
||||
this.state = config.state || getDefaultState()
|
||||
|
||||
this.setOptions(config.options)
|
||||
this.scheduleGc()
|
||||
}
|
||||
|
||||
setOptions(
|
||||
options: MutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
): void {
|
||||
this.options = options
|
||||
|
||||
this.updateGcTime(this.options.gcTime)
|
||||
}
|
||||
|
||||
get meta(): MutationMeta | undefined {
|
||||
return this.options.meta
|
||||
}
|
||||
|
||||
addObserver(observer: MutationObserver<any, any, any, any>): void {
|
||||
if (!this.#observers.includes(observer)) {
|
||||
this.#observers.push(observer)
|
||||
|
||||
// Stop the mutation from being garbage collected
|
||||
this.clearGcTimeout()
|
||||
|
||||
this.#mutationCache.notify({
|
||||
type: 'observerAdded',
|
||||
mutation: this,
|
||||
observer,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
removeObserver(observer: MutationObserver<any, any, any, any>): void {
|
||||
this.#observers = this.#observers.filter((x) => x !== observer)
|
||||
|
||||
this.scheduleGc()
|
||||
|
||||
this.#mutationCache.notify({
|
||||
type: 'observerRemoved',
|
||||
mutation: this,
|
||||
observer,
|
||||
})
|
||||
}
|
||||
|
||||
protected optionalRemove() {
|
||||
if (!this.#observers.length) {
|
||||
if (this.state.status === 'pending') {
|
||||
this.scheduleGc()
|
||||
} else {
|
||||
this.#mutationCache.remove(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue(): Promise<unknown> {
|
||||
return (
|
||||
this.#retryer?.continue() ??
|
||||
// continuing a mutation assumes that variables are set, mutation must have been dehydrated before
|
||||
this.execute(this.state.variables!)
|
||||
)
|
||||
}
|
||||
|
||||
async execute(variables: TVariables): Promise<TData> {
|
||||
const onContinue = () => {
|
||||
this.#dispatch({ type: 'continue' })
|
||||
}
|
||||
|
||||
const mutationFnContext = {
|
||||
client: this.#client,
|
||||
meta: this.options.meta,
|
||||
mutationKey: this.options.mutationKey,
|
||||
} satisfies MutationFunctionContext
|
||||
|
||||
this.#retryer = createRetryer({
|
||||
fn: () => {
|
||||
if (!this.options.mutationFn) {
|
||||
return Promise.reject(new Error('No mutationFn found'))
|
||||
}
|
||||
|
||||
return this.options.mutationFn(variables, mutationFnContext)
|
||||
},
|
||||
onFail: (failureCount, error) => {
|
||||
this.#dispatch({ type: 'failed', failureCount, error })
|
||||
},
|
||||
onPause: () => {
|
||||
this.#dispatch({ type: 'pause' })
|
||||
},
|
||||
onContinue,
|
||||
retry: this.options.retry ?? 0,
|
||||
retryDelay: this.options.retryDelay,
|
||||
networkMode: this.options.networkMode,
|
||||
canRun: () => this.#mutationCache.canRun(this),
|
||||
})
|
||||
|
||||
const restored = this.state.status === 'pending'
|
||||
const isPaused = !this.#retryer.canStart()
|
||||
|
||||
try {
|
||||
if (restored) {
|
||||
// Dispatch continue action to unpause restored mutation
|
||||
onContinue()
|
||||
} else {
|
||||
this.#dispatch({ type: 'pending', variables, isPaused })
|
||||
// Notify cache callback
|
||||
if (this.#mutationCache.config.onMutate) {
|
||||
await this.#mutationCache.config.onMutate(
|
||||
variables,
|
||||
this as Mutation<unknown, unknown, unknown, unknown>,
|
||||
mutationFnContext,
|
||||
)
|
||||
}
|
||||
const context = await this.options.onMutate?.(
|
||||
variables,
|
||||
mutationFnContext,
|
||||
)
|
||||
if (context !== this.state.context) {
|
||||
this.#dispatch({
|
||||
type: 'pending',
|
||||
context,
|
||||
variables,
|
||||
isPaused,
|
||||
})
|
||||
}
|
||||
}
|
||||
const data = await this.#retryer.start()
|
||||
|
||||
// Notify cache callback
|
||||
await this.#mutationCache.config.onSuccess?.(
|
||||
data,
|
||||
variables,
|
||||
this.state.context,
|
||||
this as Mutation<unknown, unknown, unknown, unknown>,
|
||||
mutationFnContext,
|
||||
)
|
||||
|
||||
await this.options.onSuccess?.(
|
||||
data,
|
||||
variables,
|
||||
this.state.context!,
|
||||
mutationFnContext,
|
||||
)
|
||||
|
||||
// Notify cache callback
|
||||
await this.#mutationCache.config.onSettled?.(
|
||||
data,
|
||||
null,
|
||||
this.state.variables,
|
||||
this.state.context,
|
||||
this as Mutation<unknown, unknown, unknown, unknown>,
|
||||
mutationFnContext,
|
||||
)
|
||||
|
||||
await this.options.onSettled?.(
|
||||
data,
|
||||
null,
|
||||
variables,
|
||||
this.state.context,
|
||||
mutationFnContext,
|
||||
)
|
||||
|
||||
this.#dispatch({ type: 'success', data })
|
||||
return data
|
||||
} catch (error) {
|
||||
try {
|
||||
// Notify cache callback
|
||||
await this.#mutationCache.config.onError?.(
|
||||
error as any,
|
||||
variables,
|
||||
this.state.context,
|
||||
this as Mutation<unknown, unknown, unknown, unknown>,
|
||||
mutationFnContext,
|
||||
)
|
||||
} catch (e) {
|
||||
void Promise.reject(e)
|
||||
}
|
||||
|
||||
try {
|
||||
await this.options.onError?.(
|
||||
error as TError,
|
||||
variables,
|
||||
this.state.context,
|
||||
mutationFnContext,
|
||||
)
|
||||
} catch (e) {
|
||||
void Promise.reject(e)
|
||||
}
|
||||
|
||||
try {
|
||||
// Notify cache callback
|
||||
await this.#mutationCache.config.onSettled?.(
|
||||
undefined,
|
||||
error as any,
|
||||
this.state.variables,
|
||||
this.state.context,
|
||||
this as Mutation<unknown, unknown, unknown, unknown>,
|
||||
mutationFnContext,
|
||||
)
|
||||
} catch (e) {
|
||||
void Promise.reject(e)
|
||||
}
|
||||
|
||||
try {
|
||||
await this.options.onSettled?.(
|
||||
undefined,
|
||||
error as TError,
|
||||
variables,
|
||||
this.state.context,
|
||||
mutationFnContext,
|
||||
)
|
||||
} catch (e) {
|
||||
void Promise.reject(e)
|
||||
}
|
||||
|
||||
this.#dispatch({ type: 'error', error: error as TError })
|
||||
throw error
|
||||
} finally {
|
||||
this.#mutationCache.runNext(this)
|
||||
}
|
||||
}
|
||||
|
||||
#dispatch(action: Action<TData, TError, TVariables, TOnMutateResult>): void {
|
||||
const reducer = (
|
||||
state: MutationState<TData, TError, TVariables, TOnMutateResult>,
|
||||
): MutationState<TData, TError, TVariables, TOnMutateResult> => {
|
||||
switch (action.type) {
|
||||
case 'failed':
|
||||
return {
|
||||
...state,
|
||||
failureCount: action.failureCount,
|
||||
failureReason: action.error,
|
||||
}
|
||||
case 'pause':
|
||||
return {
|
||||
...state,
|
||||
isPaused: true,
|
||||
}
|
||||
case 'continue':
|
||||
return {
|
||||
...state,
|
||||
isPaused: false,
|
||||
}
|
||||
case 'pending':
|
||||
return {
|
||||
...state,
|
||||
context: action.context,
|
||||
data: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
error: null,
|
||||
isPaused: action.isPaused,
|
||||
status: 'pending',
|
||||
variables: action.variables,
|
||||
submittedAt: Date.now(),
|
||||
}
|
||||
case 'success':
|
||||
return {
|
||||
...state,
|
||||
data: action.data,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
error: null,
|
||||
status: 'success',
|
||||
isPaused: false,
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
...state,
|
||||
data: undefined,
|
||||
error: action.error,
|
||||
failureCount: state.failureCount + 1,
|
||||
failureReason: action.error,
|
||||
isPaused: false,
|
||||
status: 'error',
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state = reducer(this.state)
|
||||
|
||||
notifyManager.batch(() => {
|
||||
this.#observers.forEach((observer) => {
|
||||
observer.onMutationUpdate(action)
|
||||
})
|
||||
this.#mutationCache.notify({
|
||||
mutation: this,
|
||||
type: 'updated',
|
||||
action,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultState<
|
||||
TData,
|
||||
TError,
|
||||
TVariables,
|
||||
TOnMutateResult,
|
||||
>(): MutationState<TData, TError, TVariables, TOnMutateResult> {
|
||||
return {
|
||||
context: undefined,
|
||||
data: undefined,
|
||||
error: null,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
isPaused: false,
|
||||
status: 'idle',
|
||||
variables: undefined,
|
||||
submittedAt: 0,
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { notifyManager } from './notifyManager'
|
||||
import { Mutation } from './mutation'
|
||||
import { matchMutation, noop } from './utils'
|
||||
import { Subscribable } from './subscribable'
|
||||
import type { MutationObserver } from './mutationObserver'
|
||||
import type {
|
||||
DefaultError,
|
||||
MutationFunctionContext,
|
||||
MutationOptions,
|
||||
NotifyEvent,
|
||||
} from './types'
|
||||
import type { QueryClient } from './queryClient'
|
||||
import type { Action, MutationState } from './mutation'
|
||||
import type { MutationFilters } from './utils'
|
||||
|
||||
// TYPES
|
||||
|
||||
interface MutationCacheConfig {
|
||||
onError?: (
|
||||
error: DefaultError,
|
||||
variables: unknown,
|
||||
onMutateResult: unknown,
|
||||
mutation: Mutation<unknown, unknown, unknown>,
|
||||
context: MutationFunctionContext,
|
||||
) => Promise<unknown> | unknown
|
||||
onSuccess?: (
|
||||
data: unknown,
|
||||
variables: unknown,
|
||||
onMutateResult: unknown,
|
||||
mutation: Mutation<unknown, unknown, unknown>,
|
||||
context: MutationFunctionContext,
|
||||
) => Promise<unknown> | unknown
|
||||
onMutate?: (
|
||||
variables: unknown,
|
||||
mutation: Mutation<unknown, unknown, unknown>,
|
||||
context: MutationFunctionContext,
|
||||
) => Promise<unknown> | unknown
|
||||
onSettled?: (
|
||||
data: unknown | undefined,
|
||||
error: DefaultError | null,
|
||||
variables: unknown,
|
||||
onMutateResult: unknown,
|
||||
mutation: Mutation<unknown, unknown, unknown>,
|
||||
context: MutationFunctionContext,
|
||||
) => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
interface NotifyEventMutationAdded extends NotifyEvent {
|
||||
type: 'added'
|
||||
mutation: Mutation<any, any, any, any>
|
||||
}
|
||||
interface NotifyEventMutationRemoved extends NotifyEvent {
|
||||
type: 'removed'
|
||||
mutation: Mutation<any, any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventMutationObserverAdded extends NotifyEvent {
|
||||
type: 'observerAdded'
|
||||
mutation: Mutation<any, any, any, any>
|
||||
observer: MutationObserver<any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventMutationObserverRemoved extends NotifyEvent {
|
||||
type: 'observerRemoved'
|
||||
mutation: Mutation<any, any, any, any>
|
||||
observer: MutationObserver<any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventMutationObserverOptionsUpdated extends NotifyEvent {
|
||||
type: 'observerOptionsUpdated'
|
||||
mutation?: Mutation<any, any, any, any>
|
||||
observer: MutationObserver<any, any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventMutationUpdated extends NotifyEvent {
|
||||
type: 'updated'
|
||||
mutation: Mutation<any, any, any, any>
|
||||
action: Action<any, any, any, any>
|
||||
}
|
||||
|
||||
export type MutationCacheNotifyEvent =
|
||||
| NotifyEventMutationAdded
|
||||
| NotifyEventMutationRemoved
|
||||
| NotifyEventMutationObserverAdded
|
||||
| NotifyEventMutationObserverRemoved
|
||||
| NotifyEventMutationObserverOptionsUpdated
|
||||
| NotifyEventMutationUpdated
|
||||
|
||||
type MutationCacheListener = (event: MutationCacheNotifyEvent) => void
|
||||
|
||||
// CLASS
|
||||
|
||||
export class MutationCache extends Subscribable<MutationCacheListener> {
|
||||
#mutations: Set<Mutation<any, any, any, any>>
|
||||
#scopes: Map<string, Array<Mutation<any, any, any, any>>>
|
||||
#mutationId: number
|
||||
|
||||
constructor(public config: MutationCacheConfig = {}) {
|
||||
super()
|
||||
this.#mutations = new Set()
|
||||
this.#scopes = new Map()
|
||||
this.#mutationId = 0
|
||||
}
|
||||
|
||||
build<TData, TError, TVariables, TOnMutateResult>(
|
||||
client: QueryClient,
|
||||
options: MutationOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
state?: MutationState<TData, TError, TVariables, TOnMutateResult>,
|
||||
): Mutation<TData, TError, TVariables, TOnMutateResult> {
|
||||
const mutation = new Mutation({
|
||||
client,
|
||||
mutationCache: this,
|
||||
mutationId: ++this.#mutationId,
|
||||
options: client.defaultMutationOptions(options),
|
||||
state,
|
||||
})
|
||||
|
||||
this.add(mutation)
|
||||
|
||||
return mutation
|
||||
}
|
||||
|
||||
add(mutation: Mutation<any, any, any, any>): void {
|
||||
this.#mutations.add(mutation)
|
||||
const scope = scopeFor(mutation)
|
||||
if (typeof scope === 'string') {
|
||||
const scopedMutations = this.#scopes.get(scope)
|
||||
if (scopedMutations) {
|
||||
scopedMutations.push(mutation)
|
||||
} else {
|
||||
this.#scopes.set(scope, [mutation])
|
||||
}
|
||||
}
|
||||
this.notify({ type: 'added', mutation })
|
||||
}
|
||||
|
||||
remove(mutation: Mutation<any, any, any, any>): void {
|
||||
if (this.#mutations.delete(mutation)) {
|
||||
const scope = scopeFor(mutation)
|
||||
if (typeof scope === 'string') {
|
||||
const scopedMutations = this.#scopes.get(scope)
|
||||
if (scopedMutations) {
|
||||
if (scopedMutations.length > 1) {
|
||||
const index = scopedMutations.indexOf(mutation)
|
||||
if (index !== -1) {
|
||||
scopedMutations.splice(index, 1)
|
||||
}
|
||||
} else if (scopedMutations[0] === mutation) {
|
||||
this.#scopes.delete(scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Currently we notify the removal even if the mutation was already removed.
|
||||
// Consider making this an error or not notifying of the removal depending on the desired semantics.
|
||||
this.notify({ type: 'removed', mutation })
|
||||
}
|
||||
|
||||
canRun(mutation: Mutation<any, any, any, any>): boolean {
|
||||
const scope = scopeFor(mutation)
|
||||
if (typeof scope === 'string') {
|
||||
const mutationsWithSameScope = this.#scopes.get(scope)
|
||||
const firstPendingMutation = mutationsWithSameScope?.find(
|
||||
(m) => m.state.status === 'pending',
|
||||
)
|
||||
// we can run if there is no current pending mutation (start use-case)
|
||||
// or if WE are the first pending mutation (continue use-case)
|
||||
return !firstPendingMutation || firstPendingMutation === mutation
|
||||
} else {
|
||||
// For unscoped mutations there are never any pending mutations in front of the
|
||||
// current mutation
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
runNext(mutation: Mutation<any, any, any, any>): Promise<unknown> {
|
||||
const scope = scopeFor(mutation)
|
||||
if (typeof scope === 'string') {
|
||||
const foundMutation = this.#scopes
|
||||
.get(scope)
|
||||
?.find((m) => m !== mutation && m.state.isPaused)
|
||||
|
||||
return foundMutation?.continue() ?? Promise.resolve()
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
notifyManager.batch(() => {
|
||||
this.#mutations.forEach((mutation) => {
|
||||
this.notify({ type: 'removed', mutation })
|
||||
})
|
||||
this.#mutations.clear()
|
||||
this.#scopes.clear()
|
||||
})
|
||||
}
|
||||
|
||||
getAll(): Array<Mutation> {
|
||||
return Array.from(this.#mutations)
|
||||
}
|
||||
|
||||
find<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = any,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
filters: MutationFilters,
|
||||
): Mutation<TData, TError, TVariables, TOnMutateResult> | undefined {
|
||||
const defaultedFilters = { exact: true, ...filters }
|
||||
|
||||
return this.getAll().find((mutation) =>
|
||||
matchMutation(defaultedFilters, mutation),
|
||||
) as Mutation<TData, TError, TVariables, TOnMutateResult> | undefined
|
||||
}
|
||||
|
||||
findAll(filters: MutationFilters = {}): Array<Mutation> {
|
||||
return this.getAll().filter((mutation) => matchMutation(filters, mutation))
|
||||
}
|
||||
|
||||
notify(event: MutationCacheNotifyEvent) {
|
||||
notifyManager.batch(() => {
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
resumePausedMutations(): Promise<unknown> {
|
||||
const pausedMutations = this.getAll().filter((x) => x.state.isPaused)
|
||||
|
||||
return notifyManager.batch(() =>
|
||||
Promise.all(
|
||||
pausedMutations.map((mutation) => mutation.continue().catch(noop)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function scopeFor(mutation: Mutation<any, any, any, any>) {
|
||||
return mutation.options.scope?.id
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
// TYPES
|
||||
|
||||
import { systemSetTimeoutZero } from './timeoutManager'
|
||||
|
||||
type NotifyCallback = () => void
|
||||
|
||||
type NotifyFunction = (callback: () => void) => void
|
||||
|
||||
type BatchNotifyFunction = (callback: () => void) => void
|
||||
|
||||
type BatchCallsCallback<T extends Array<unknown>> = (...args: T) => void
|
||||
|
||||
type ScheduleFunction = (callback: () => void) => void
|
||||
|
||||
export const defaultScheduler: ScheduleFunction = systemSetTimeoutZero
|
||||
|
||||
export function createNotifyManager() {
|
||||
let queue: Array<NotifyCallback> = []
|
||||
let transactions = 0
|
||||
let notifyFn: NotifyFunction = (callback) => {
|
||||
callback()
|
||||
}
|
||||
let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => {
|
||||
callback()
|
||||
}
|
||||
let scheduleFn = defaultScheduler
|
||||
|
||||
const schedule = (callback: NotifyCallback): void => {
|
||||
if (transactions) {
|
||||
queue.push(callback)
|
||||
} else {
|
||||
scheduleFn(() => {
|
||||
notifyFn(callback)
|
||||
})
|
||||
}
|
||||
}
|
||||
const flush = (): void => {
|
||||
const originalQueue = queue
|
||||
queue = []
|
||||
if (originalQueue.length) {
|
||||
scheduleFn(() => {
|
||||
batchNotifyFn(() => {
|
||||
originalQueue.forEach((callback) => {
|
||||
notifyFn(callback)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
batch: <T>(callback: () => T): T => {
|
||||
let result
|
||||
transactions++
|
||||
try {
|
||||
result = callback()
|
||||
} finally {
|
||||
transactions--
|
||||
if (!transactions) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
/**
|
||||
* All calls to the wrapped function will be batched.
|
||||
*/
|
||||
batchCalls: <T extends Array<unknown>>(
|
||||
callback: BatchCallsCallback<T>,
|
||||
): BatchCallsCallback<T> => {
|
||||
return (...args) => {
|
||||
schedule(() => {
|
||||
callback(...args)
|
||||
})
|
||||
}
|
||||
},
|
||||
schedule,
|
||||
/**
|
||||
* Use this method to set a custom notify function.
|
||||
* This can be used to for example wrap notifications with `React.act` while running tests.
|
||||
*/
|
||||
setNotifyFunction: (fn: NotifyFunction) => {
|
||||
notifyFn = fn
|
||||
},
|
||||
/**
|
||||
* Use this method to set a custom function to batch notifications together into a single tick.
|
||||
* By default React Query will use the batch function provided by ReactDOM or React Native.
|
||||
*/
|
||||
setBatchNotifyFunction: (fn: BatchNotifyFunction) => {
|
||||
batchNotifyFn = fn
|
||||
},
|
||||
setScheduler: (fn: ScheduleFunction) => {
|
||||
scheduleFn = fn
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
// SINGLETON
|
||||
export const notifyManager = createNotifyManager()
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { Subscribable } from './subscribable'
|
||||
import { isServer } from './utils'
|
||||
|
||||
type Listener = (online: boolean) => void
|
||||
type SetupFn = (setOnline: Listener) => (() => void) | undefined
|
||||
|
||||
export class OnlineManager extends Subscribable<Listener> {
|
||||
#online = true
|
||||
#cleanup?: () => void
|
||||
|
||||
#setup: SetupFn
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.#setup = (onOnline) => {
|
||||
// addEventListener does not exist in React Native, but window does
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!isServer && window.addEventListener) {
|
||||
const onlineListener = () => onOnline(true)
|
||||
const offlineListener = () => onOnline(false)
|
||||
// Listen to online
|
||||
window.addEventListener('online', onlineListener, false)
|
||||
window.addEventListener('offline', offlineListener, false)
|
||||
|
||||
return () => {
|
||||
// Be sure to unsubscribe if a new handler is set
|
||||
window.removeEventListener('online', onlineListener)
|
||||
window.removeEventListener('offline', offlineListener)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
protected onSubscribe(): void {
|
||||
if (!this.#cleanup) {
|
||||
this.setEventListener(this.#setup)
|
||||
}
|
||||
}
|
||||
|
||||
protected onUnsubscribe() {
|
||||
if (!this.hasListeners()) {
|
||||
this.#cleanup?.()
|
||||
this.#cleanup = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setEventListener(setup: SetupFn): void {
|
||||
this.#setup = setup
|
||||
this.#cleanup?.()
|
||||
this.#cleanup = setup(this.setOnline.bind(this))
|
||||
}
|
||||
|
||||
setOnline(online: boolean): void {
|
||||
const changed = this.#online !== online
|
||||
|
||||
if (changed) {
|
||||
this.#online = online
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(online)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
isOnline(): boolean {
|
||||
return this.#online
|
||||
}
|
||||
}
|
||||
|
||||
export const onlineManager = new OnlineManager()
|
||||
+756
@@ -0,0 +1,756 @@
|
||||
import {
|
||||
ensureQueryFn,
|
||||
noop,
|
||||
replaceData,
|
||||
resolveEnabled,
|
||||
resolveStaleTime,
|
||||
skipToken,
|
||||
timeUntilStale,
|
||||
} from './utils'
|
||||
import { notifyManager } from './notifyManager'
|
||||
import { CancelledError, canFetch, createRetryer } from './retryer'
|
||||
import { Removable } from './removable'
|
||||
import type { QueryCache } from './queryCache'
|
||||
import type { QueryClient } from './queryClient'
|
||||
import type {
|
||||
CancelOptions,
|
||||
DefaultError,
|
||||
FetchStatus,
|
||||
InitialDataFunction,
|
||||
OmitKeyof,
|
||||
QueryFunctionContext,
|
||||
QueryKey,
|
||||
QueryMeta,
|
||||
QueryOptions,
|
||||
QueryStatus,
|
||||
SetDataOptions,
|
||||
StaleTime,
|
||||
} from './types'
|
||||
import type { QueryObserver } from './queryObserver'
|
||||
import type { Retryer } from './retryer'
|
||||
|
||||
// TYPES
|
||||
|
||||
interface QueryConfig<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> {
|
||||
client: QueryClient
|
||||
queryKey: TQueryKey
|
||||
queryHash: string
|
||||
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
|
||||
defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
|
||||
state?: QueryState<TData, TError>
|
||||
}
|
||||
|
||||
export interface QueryState<TData = unknown, TError = DefaultError> {
|
||||
data: TData | undefined
|
||||
dataUpdateCount: number
|
||||
dataUpdatedAt: number
|
||||
error: TError | null
|
||||
errorUpdateCount: number
|
||||
errorUpdatedAt: number
|
||||
fetchFailureCount: number
|
||||
fetchFailureReason: TError | null
|
||||
fetchMeta: FetchMeta | null
|
||||
isInvalidated: boolean
|
||||
status: QueryStatus
|
||||
fetchStatus: FetchStatus
|
||||
}
|
||||
|
||||
export interface FetchContext<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> {
|
||||
fetchFn: () => unknown | Promise<unknown>
|
||||
fetchOptions?: FetchOptions
|
||||
signal: AbortSignal
|
||||
options: QueryOptions<TQueryFnData, TError, TData, any>
|
||||
client: QueryClient
|
||||
queryKey: TQueryKey
|
||||
state: QueryState<TData, TError>
|
||||
}
|
||||
|
||||
export interface QueryBehavior<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> {
|
||||
onFetch: (
|
||||
context: FetchContext<TQueryFnData, TError, TData, TQueryKey>,
|
||||
query: Query,
|
||||
) => void
|
||||
}
|
||||
|
||||
export type FetchDirection = 'forward' | 'backward'
|
||||
|
||||
export interface FetchMeta {
|
||||
fetchMore?: { direction: FetchDirection }
|
||||
}
|
||||
|
||||
export interface FetchOptions<TData = unknown> {
|
||||
cancelRefetch?: boolean
|
||||
meta?: FetchMeta
|
||||
initialPromise?: Promise<TData>
|
||||
}
|
||||
|
||||
interface FailedAction<TError> {
|
||||
type: 'failed'
|
||||
failureCount: number
|
||||
error: TError
|
||||
}
|
||||
|
||||
interface FetchAction {
|
||||
type: 'fetch'
|
||||
meta?: FetchMeta
|
||||
}
|
||||
|
||||
interface SuccessAction<TData> {
|
||||
data: TData | undefined
|
||||
type: 'success'
|
||||
dataUpdatedAt?: number
|
||||
manual?: boolean
|
||||
}
|
||||
|
||||
interface ErrorAction<TError> {
|
||||
type: 'error'
|
||||
error: TError
|
||||
}
|
||||
|
||||
interface InvalidateAction {
|
||||
type: 'invalidate'
|
||||
}
|
||||
|
||||
interface PauseAction {
|
||||
type: 'pause'
|
||||
}
|
||||
|
||||
interface ContinueAction {
|
||||
type: 'continue'
|
||||
}
|
||||
|
||||
interface SetStateAction<TData, TError> {
|
||||
type: 'setState'
|
||||
state: Partial<QueryState<TData, TError>>
|
||||
setStateOptions?: SetStateOptions
|
||||
}
|
||||
|
||||
export type Action<TData, TError> =
|
||||
| ContinueAction
|
||||
| ErrorAction<TError>
|
||||
| FailedAction<TError>
|
||||
| FetchAction
|
||||
| InvalidateAction
|
||||
| PauseAction
|
||||
| SetStateAction<TData, TError>
|
||||
| SuccessAction<TData>
|
||||
|
||||
export interface SetStateOptions {
|
||||
meta?: any
|
||||
}
|
||||
|
||||
// CLASS
|
||||
|
||||
export class Query<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> extends Removable {
|
||||
queryKey: TQueryKey
|
||||
queryHash: string
|
||||
options!: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
|
||||
state: QueryState<TData, TError>
|
||||
|
||||
#initialState: QueryState<TData, TError>
|
||||
#revertState?: QueryState<TData, TError>
|
||||
#cache: QueryCache
|
||||
#client: QueryClient
|
||||
#retryer?: Retryer<TData>
|
||||
observers: Array<QueryObserver<any, any, any, any, any>>
|
||||
#defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
|
||||
#abortSignalConsumed: boolean
|
||||
|
||||
constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
|
||||
super()
|
||||
|
||||
this.#abortSignalConsumed = false
|
||||
this.#defaultOptions = config.defaultOptions
|
||||
this.setOptions(config.options)
|
||||
this.observers = []
|
||||
this.#client = config.client
|
||||
this.#cache = this.#client.getQueryCache()
|
||||
this.queryKey = config.queryKey
|
||||
this.queryHash = config.queryHash
|
||||
this.#initialState = getDefaultState(this.options)
|
||||
this.state = config.state ?? this.#initialState
|
||||
this.scheduleGc()
|
||||
}
|
||||
get meta(): QueryMeta | undefined {
|
||||
return this.options.meta
|
||||
}
|
||||
|
||||
get promise(): Promise<TData> | undefined {
|
||||
return this.#retryer?.promise
|
||||
}
|
||||
|
||||
setOptions(
|
||||
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): void {
|
||||
this.options = { ...this.#defaultOptions, ...options }
|
||||
|
||||
this.updateGcTime(this.options.gcTime)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (this.state && this.state.data === undefined) {
|
||||
const defaultState = getDefaultState(this.options)
|
||||
if (defaultState.data !== undefined) {
|
||||
this.setState(
|
||||
successState(defaultState.data, defaultState.dataUpdatedAt),
|
||||
)
|
||||
this.#initialState = defaultState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected optionalRemove() {
|
||||
if (!this.observers.length && this.state.fetchStatus === 'idle') {
|
||||
this.#cache.remove(this)
|
||||
}
|
||||
}
|
||||
|
||||
setData(
|
||||
newData: TData,
|
||||
options?: SetDataOptions & { manual: boolean },
|
||||
): TData {
|
||||
const data = replaceData(this.state.data, newData, this.options)
|
||||
|
||||
// Set data and mark it as cached
|
||||
this.#dispatch({
|
||||
data,
|
||||
type: 'success',
|
||||
dataUpdatedAt: options?.updatedAt,
|
||||
manual: options?.manual,
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
setState(
|
||||
state: Partial<QueryState<TData, TError>>,
|
||||
setStateOptions?: SetStateOptions,
|
||||
): void {
|
||||
this.#dispatch({ type: 'setState', state, setStateOptions })
|
||||
}
|
||||
|
||||
cancel(options?: CancelOptions): Promise<void> {
|
||||
const promise = this.#retryer?.promise
|
||||
this.#retryer?.cancel(options)
|
||||
return promise ? promise.then(noop).catch(noop) : Promise.resolve()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
super.destroy()
|
||||
|
||||
this.cancel({ silent: true })
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.destroy()
|
||||
this.setState(this.#initialState)
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.observers.some(
|
||||
(observer) => resolveEnabled(observer.options.enabled, this) !== false,
|
||||
)
|
||||
}
|
||||
|
||||
isDisabled(): boolean {
|
||||
if (this.getObserversCount() > 0) {
|
||||
return !this.isActive()
|
||||
}
|
||||
// if a query has no observers, it should still be considered disabled if it never attempted a fetch
|
||||
return (
|
||||
this.options.queryFn === skipToken ||
|
||||
this.state.dataUpdateCount + this.state.errorUpdateCount === 0
|
||||
)
|
||||
}
|
||||
|
||||
isStatic(): boolean {
|
||||
if (this.getObserversCount() > 0) {
|
||||
return this.observers.some(
|
||||
(observer) =>
|
||||
resolveStaleTime(observer.options.staleTime, this) === 'static',
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
isStale(): boolean {
|
||||
// check observers first, their `isStale` has the source of truth
|
||||
// calculated with `isStaleByTime` and it takes `enabled` into account
|
||||
if (this.getObserversCount() > 0) {
|
||||
return this.observers.some(
|
||||
(observer) => observer.getCurrentResult().isStale,
|
||||
)
|
||||
}
|
||||
|
||||
return this.state.data === undefined || this.state.isInvalidated
|
||||
}
|
||||
|
||||
isStaleByTime(staleTime: StaleTime = 0): boolean {
|
||||
// no data is always stale
|
||||
if (this.state.data === undefined) {
|
||||
return true
|
||||
}
|
||||
// static is never stale
|
||||
if (staleTime === 'static') {
|
||||
return false
|
||||
}
|
||||
// if the query is invalidated, it is stale
|
||||
if (this.state.isInvalidated) {
|
||||
return true
|
||||
}
|
||||
|
||||
return !timeUntilStale(this.state.dataUpdatedAt, staleTime)
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
const observer = this.observers.find((x) => x.shouldFetchOnWindowFocus())
|
||||
|
||||
observer?.refetch({ cancelRefetch: false })
|
||||
|
||||
// Continue fetch if currently paused
|
||||
this.#retryer?.continue()
|
||||
}
|
||||
|
||||
onOnline(): void {
|
||||
const observer = this.observers.find((x) => x.shouldFetchOnReconnect())
|
||||
|
||||
observer?.refetch({ cancelRefetch: false })
|
||||
|
||||
// Continue fetch if currently paused
|
||||
this.#retryer?.continue()
|
||||
}
|
||||
|
||||
addObserver(observer: QueryObserver<any, any, any, any, any>): void {
|
||||
if (!this.observers.includes(observer)) {
|
||||
this.observers.push(observer)
|
||||
|
||||
// Stop the query from being garbage collected
|
||||
this.clearGcTimeout()
|
||||
|
||||
this.#cache.notify({ type: 'observerAdded', query: this, observer })
|
||||
}
|
||||
}
|
||||
|
||||
removeObserver(observer: QueryObserver<any, any, any, any, any>): void {
|
||||
if (this.observers.includes(observer)) {
|
||||
this.observers = this.observers.filter((x) => x !== observer)
|
||||
|
||||
if (!this.observers.length) {
|
||||
// If the transport layer does not support cancellation
|
||||
// we'll let the query continue so the result can be cached
|
||||
if (this.#retryer) {
|
||||
if (this.#abortSignalConsumed) {
|
||||
this.#retryer.cancel({ revert: true })
|
||||
} else {
|
||||
this.#retryer.cancelRetry()
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleGc()
|
||||
}
|
||||
|
||||
this.#cache.notify({ type: 'observerRemoved', query: this, observer })
|
||||
}
|
||||
}
|
||||
|
||||
getObserversCount(): number {
|
||||
return this.observers.length
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
if (!this.state.isInvalidated) {
|
||||
this.#dispatch({ type: 'invalidate' })
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(
|
||||
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
fetchOptions?: FetchOptions<TQueryFnData>,
|
||||
): Promise<TData> {
|
||||
if (
|
||||
this.state.fetchStatus !== 'idle' &&
|
||||
// If the promise in the retryer is already rejected, we have to definitely
|
||||
// re-start the fetch; there is a chance that the query is still in a
|
||||
// pending state when that happens
|
||||
this.#retryer?.status() !== 'rejected'
|
||||
) {
|
||||
if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
|
||||
// Silently cancel current fetch if the user wants to cancel refetch
|
||||
this.cancel({ silent: true })
|
||||
} else if (this.#retryer) {
|
||||
// make sure that retries that were potentially cancelled due to unmounts can continue
|
||||
this.#retryer.continueRetry()
|
||||
// Return current promise if we are already fetching
|
||||
return this.#retryer.promise
|
||||
}
|
||||
}
|
||||
|
||||
// Update config if passed, otherwise the config from the last execution is used
|
||||
if (options) {
|
||||
this.setOptions(options)
|
||||
}
|
||||
|
||||
// Use the options from the first observer with a query function if no function is found.
|
||||
// This can happen when the query is hydrated or created with setQueryData.
|
||||
if (!this.options.queryFn) {
|
||||
const observer = this.observers.find((x) => x.options.queryFn)
|
||||
if (observer) {
|
||||
this.setOptions(observer.options)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!Array.isArray(this.options.queryKey)) {
|
||||
console.error(
|
||||
`As of v4, queryKey needs to be an Array. If you are using a string like 'repoData', please change it to an Array, e.g. ['repoData']`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
// Adds an enumerable signal property to the object that
|
||||
// which sets abortSignalConsumed to true when the signal
|
||||
// is read.
|
||||
const addSignalProperty = (object: unknown) => {
|
||||
Object.defineProperty(object, 'signal', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
this.#abortSignalConsumed = true
|
||||
return abortController.signal
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create fetch function
|
||||
const fetchFn = () => {
|
||||
const queryFn = ensureQueryFn(this.options, fetchOptions)
|
||||
|
||||
// Create query function context
|
||||
const createQueryFnContext = (): QueryFunctionContext<TQueryKey> => {
|
||||
const queryFnContext: OmitKeyof<
|
||||
QueryFunctionContext<TQueryKey>,
|
||||
'signal'
|
||||
> = {
|
||||
client: this.#client,
|
||||
queryKey: this.queryKey,
|
||||
meta: this.meta,
|
||||
}
|
||||
addSignalProperty(queryFnContext)
|
||||
return queryFnContext as QueryFunctionContext<TQueryKey>
|
||||
}
|
||||
|
||||
const queryFnContext = createQueryFnContext()
|
||||
|
||||
this.#abortSignalConsumed = false
|
||||
if (this.options.persister) {
|
||||
return this.options.persister(
|
||||
queryFn,
|
||||
queryFnContext,
|
||||
this as unknown as Query,
|
||||
)
|
||||
}
|
||||
|
||||
return queryFn(queryFnContext)
|
||||
}
|
||||
|
||||
// Trigger behavior hook
|
||||
const createFetchContext = (): FetchContext<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey
|
||||
> => {
|
||||
const context: OmitKeyof<
|
||||
FetchContext<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'signal'
|
||||
> = {
|
||||
fetchOptions,
|
||||
options: this.options,
|
||||
queryKey: this.queryKey,
|
||||
client: this.#client,
|
||||
state: this.state,
|
||||
fetchFn,
|
||||
}
|
||||
|
||||
addSignalProperty(context)
|
||||
return context as FetchContext<TQueryFnData, TError, TData, TQueryKey>
|
||||
}
|
||||
|
||||
const context = createFetchContext()
|
||||
|
||||
this.options.behavior?.onFetch(context, this as unknown as Query)
|
||||
|
||||
// Store state in case the current fetch needs to be reverted
|
||||
this.#revertState = this.state
|
||||
|
||||
// Set to fetching state if not already in it
|
||||
if (
|
||||
this.state.fetchStatus === 'idle' ||
|
||||
this.state.fetchMeta !== context.fetchOptions?.meta
|
||||
) {
|
||||
this.#dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
|
||||
}
|
||||
|
||||
// Try to fetch the data
|
||||
this.#retryer = createRetryer({
|
||||
initialPromise: fetchOptions?.initialPromise as
|
||||
| Promise<TData>
|
||||
| undefined,
|
||||
fn: context.fetchFn as () => Promise<TData>,
|
||||
onCancel: (error) => {
|
||||
if (error instanceof CancelledError && error.revert) {
|
||||
this.setState({
|
||||
...this.#revertState,
|
||||
fetchStatus: 'idle' as const,
|
||||
})
|
||||
}
|
||||
abortController.abort()
|
||||
},
|
||||
onFail: (failureCount, error) => {
|
||||
this.#dispatch({ type: 'failed', failureCount, error })
|
||||
},
|
||||
onPause: () => {
|
||||
this.#dispatch({ type: 'pause' })
|
||||
},
|
||||
onContinue: () => {
|
||||
this.#dispatch({ type: 'continue' })
|
||||
},
|
||||
retry: context.options.retry,
|
||||
retryDelay: context.options.retryDelay,
|
||||
networkMode: context.options.networkMode,
|
||||
canRun: () => true,
|
||||
})
|
||||
|
||||
try {
|
||||
const data = await this.#retryer.start()
|
||||
// this is more of a runtime guard
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (data === undefined) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error(
|
||||
`Query data cannot be undefined. Please make sure to return a value other than undefined from your query function. Affected query key: ${this.queryHash}`,
|
||||
)
|
||||
}
|
||||
throw new Error(`${this.queryHash} data is undefined`)
|
||||
}
|
||||
|
||||
this.setData(data)
|
||||
|
||||
// Notify cache callback
|
||||
this.#cache.config.onSuccess?.(data, this as Query<any, any, any, any>)
|
||||
this.#cache.config.onSettled?.(
|
||||
data,
|
||||
this.state.error as any,
|
||||
this as Query<any, any, any, any>,
|
||||
)
|
||||
return data
|
||||
} catch (error) {
|
||||
if (error instanceof CancelledError) {
|
||||
if (error.silent) {
|
||||
// silent cancellation implies a new fetch is going to be started,
|
||||
// so we piggyback onto that promise
|
||||
return this.#retryer.promise
|
||||
} else if (error.revert) {
|
||||
// transform error into reverted state data
|
||||
// if the initial fetch was cancelled, we have no data, so we have
|
||||
// to get reject with a CancelledError
|
||||
if (this.state.data === undefined) {
|
||||
throw error
|
||||
}
|
||||
return this.state.data
|
||||
}
|
||||
}
|
||||
this.#dispatch({
|
||||
type: 'error',
|
||||
error: error as TError,
|
||||
})
|
||||
|
||||
// Notify cache callback
|
||||
this.#cache.config.onError?.(
|
||||
error as any,
|
||||
this as Query<any, any, any, any>,
|
||||
)
|
||||
this.#cache.config.onSettled?.(
|
||||
this.state.data,
|
||||
error as any,
|
||||
this as Query<any, any, any, any>,
|
||||
)
|
||||
|
||||
throw error // rethrow the error for further handling
|
||||
} finally {
|
||||
// Schedule query gc after fetching
|
||||
this.scheduleGc()
|
||||
}
|
||||
}
|
||||
|
||||
#dispatch(action: Action<TData, TError>): void {
|
||||
const reducer = (
|
||||
state: QueryState<TData, TError>,
|
||||
): QueryState<TData, TError> => {
|
||||
switch (action.type) {
|
||||
case 'failed':
|
||||
return {
|
||||
...state,
|
||||
fetchFailureCount: action.failureCount,
|
||||
fetchFailureReason: action.error,
|
||||
}
|
||||
case 'pause':
|
||||
return {
|
||||
...state,
|
||||
fetchStatus: 'paused',
|
||||
}
|
||||
case 'continue':
|
||||
return {
|
||||
...state,
|
||||
fetchStatus: 'fetching',
|
||||
}
|
||||
case 'fetch':
|
||||
return {
|
||||
...state,
|
||||
...fetchState(state.data, this.options),
|
||||
fetchMeta: action.meta ?? null,
|
||||
}
|
||||
case 'success':
|
||||
const newState = {
|
||||
...state,
|
||||
...successState(action.data, action.dataUpdatedAt),
|
||||
dataUpdateCount: state.dataUpdateCount + 1,
|
||||
...(!action.manual && {
|
||||
fetchStatus: 'idle' as const,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
}),
|
||||
}
|
||||
// If fetching ends successfully, we don't need revertState as a fallback anymore.
|
||||
// For manual updates, capture the state to revert to it in case of a cancellation.
|
||||
this.#revertState = action.manual ? newState : undefined
|
||||
|
||||
return newState
|
||||
case 'error':
|
||||
const error = action.error
|
||||
return {
|
||||
...state,
|
||||
error,
|
||||
errorUpdateCount: state.errorUpdateCount + 1,
|
||||
errorUpdatedAt: Date.now(),
|
||||
fetchFailureCount: state.fetchFailureCount + 1,
|
||||
fetchFailureReason: error,
|
||||
fetchStatus: 'idle',
|
||||
status: 'error',
|
||||
// flag existing data as invalidated if we get a background error
|
||||
// note that "no data" always means stale so we can set unconditionally here
|
||||
isInvalidated: true,
|
||||
}
|
||||
case 'invalidate':
|
||||
return {
|
||||
...state,
|
||||
isInvalidated: true,
|
||||
}
|
||||
case 'setState':
|
||||
return {
|
||||
...state,
|
||||
...action.state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.state = reducer(this.state)
|
||||
|
||||
notifyManager.batch(() => {
|
||||
this.observers.forEach((observer) => {
|
||||
observer.onQueryUpdate()
|
||||
})
|
||||
|
||||
this.#cache.notify({ query: this, type: 'updated', action })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function fetchState<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
data: TData | undefined,
|
||||
options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
) {
|
||||
return {
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
fetchStatus: canFetch(options.networkMode) ? 'fetching' : 'paused',
|
||||
...(data === undefined &&
|
||||
({
|
||||
error: null,
|
||||
status: 'pending',
|
||||
} as const)),
|
||||
} as const
|
||||
}
|
||||
|
||||
function successState<TData>(data: TData | undefined, dataUpdatedAt?: number) {
|
||||
return {
|
||||
data,
|
||||
dataUpdatedAt: dataUpdatedAt ?? Date.now(),
|
||||
error: null,
|
||||
isInvalidated: false,
|
||||
status: 'success' as const,
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultState<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): QueryState<TData, TError> {
|
||||
const data =
|
||||
typeof options.initialData === 'function'
|
||||
? (options.initialData as InitialDataFunction<TData>)()
|
||||
: options.initialData
|
||||
|
||||
const hasData = data !== undefined
|
||||
|
||||
const initialDataUpdatedAt = hasData
|
||||
? typeof options.initialDataUpdatedAt === 'function'
|
||||
? options.initialDataUpdatedAt()
|
||||
: options.initialDataUpdatedAt
|
||||
: 0
|
||||
|
||||
return {
|
||||
data,
|
||||
dataUpdateCount: 0,
|
||||
dataUpdatedAt: hasData ? (initialDataUpdatedAt ?? Date.now()) : 0,
|
||||
error: null,
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
fetchFailureCount: 0,
|
||||
fetchFailureReason: null,
|
||||
fetchMeta: null,
|
||||
isInvalidated: false,
|
||||
status: hasData ? 'success' : 'pending',
|
||||
fetchStatus: 'idle',
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { hashQueryKeyByOptions, matchQuery } from './utils'
|
||||
import { Query } from './query'
|
||||
import { notifyManager } from './notifyManager'
|
||||
import { Subscribable } from './subscribable'
|
||||
import type { QueryFilters } from './utils'
|
||||
import type { Action, QueryState } from './query'
|
||||
import type {
|
||||
DefaultError,
|
||||
NotifyEvent,
|
||||
QueryKey,
|
||||
QueryOptions,
|
||||
WithRequired,
|
||||
} from './types'
|
||||
import type { QueryClient } from './queryClient'
|
||||
import type { QueryObserver } from './queryObserver'
|
||||
|
||||
// TYPES
|
||||
|
||||
interface QueryCacheConfig {
|
||||
onError?: (
|
||||
error: DefaultError,
|
||||
query: Query<unknown, unknown, unknown>,
|
||||
) => void
|
||||
onSuccess?: (data: unknown, query: Query<unknown, unknown, unknown>) => void
|
||||
onSettled?: (
|
||||
data: unknown | undefined,
|
||||
error: DefaultError | null,
|
||||
query: Query<unknown, unknown, unknown>,
|
||||
) => void
|
||||
}
|
||||
|
||||
interface NotifyEventQueryAdded extends NotifyEvent {
|
||||
type: 'added'
|
||||
query: Query<any, any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventQueryRemoved extends NotifyEvent {
|
||||
type: 'removed'
|
||||
query: Query<any, any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventQueryUpdated extends NotifyEvent {
|
||||
type: 'updated'
|
||||
query: Query<any, any, any, any>
|
||||
action: Action<any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventQueryObserverAdded extends NotifyEvent {
|
||||
type: 'observerAdded'
|
||||
query: Query<any, any, any, any>
|
||||
observer: QueryObserver<any, any, any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventQueryObserverRemoved extends NotifyEvent {
|
||||
type: 'observerRemoved'
|
||||
query: Query<any, any, any, any>
|
||||
observer: QueryObserver<any, any, any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventQueryObserverResultsUpdated extends NotifyEvent {
|
||||
type: 'observerResultsUpdated'
|
||||
query: Query<any, any, any, any>
|
||||
}
|
||||
|
||||
interface NotifyEventQueryObserverOptionsUpdated extends NotifyEvent {
|
||||
type: 'observerOptionsUpdated'
|
||||
query: Query<any, any, any, any>
|
||||
observer: QueryObserver<any, any, any, any, any>
|
||||
}
|
||||
|
||||
export type QueryCacheNotifyEvent =
|
||||
| NotifyEventQueryAdded
|
||||
| NotifyEventQueryRemoved
|
||||
| NotifyEventQueryUpdated
|
||||
| NotifyEventQueryObserverAdded
|
||||
| NotifyEventQueryObserverRemoved
|
||||
| NotifyEventQueryObserverResultsUpdated
|
||||
| NotifyEventQueryObserverOptionsUpdated
|
||||
|
||||
type QueryCacheListener = (event: QueryCacheNotifyEvent) => void
|
||||
|
||||
export interface QueryStore {
|
||||
has: (queryHash: string) => boolean
|
||||
set: (queryHash: string, query: Query) => void
|
||||
get: (queryHash: string) => Query | undefined
|
||||
delete: (queryHash: string) => void
|
||||
values: () => IterableIterator<Query>
|
||||
}
|
||||
|
||||
// CLASS
|
||||
|
||||
export class QueryCache extends Subscribable<QueryCacheListener> {
|
||||
#queries: QueryStore
|
||||
|
||||
constructor(public config: QueryCacheConfig = {}) {
|
||||
super()
|
||||
this.#queries = new Map<string, Query>()
|
||||
}
|
||||
|
||||
build<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
client: QueryClient,
|
||||
options: WithRequired<
|
||||
QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
'queryKey'
|
||||
>,
|
||||
state?: QueryState<TData, TError>,
|
||||
): Query<TQueryFnData, TError, TData, TQueryKey> {
|
||||
const queryKey = options.queryKey
|
||||
const queryHash =
|
||||
options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
|
||||
let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)
|
||||
|
||||
if (!query) {
|
||||
query = new Query({
|
||||
client,
|
||||
queryKey,
|
||||
queryHash,
|
||||
options: client.defaultQueryOptions(options),
|
||||
state,
|
||||
defaultOptions: client.getQueryDefaults(queryKey),
|
||||
})
|
||||
this.add(query)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
add(query: Query<any, any, any, any>): void {
|
||||
if (!this.#queries.has(query.queryHash)) {
|
||||
this.#queries.set(query.queryHash, query)
|
||||
|
||||
this.notify({
|
||||
type: 'added',
|
||||
query,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
remove(query: Query<any, any, any, any>): void {
|
||||
const queryInMap = this.#queries.get(query.queryHash)
|
||||
|
||||
if (queryInMap) {
|
||||
query.destroy()
|
||||
|
||||
if (queryInMap === query) {
|
||||
this.#queries.delete(query.queryHash)
|
||||
}
|
||||
|
||||
this.notify({ type: 'removed', query })
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
notifyManager.batch(() => {
|
||||
this.getAll().forEach((query) => {
|
||||
this.remove(query)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
queryHash: string,
|
||||
): Query<TQueryFnData, TError, TData, TQueryKey> | undefined {
|
||||
return this.#queries.get(queryHash) as
|
||||
| Query<TQueryFnData, TError, TData, TQueryKey>
|
||||
| undefined
|
||||
}
|
||||
|
||||
getAll(): Array<Query> {
|
||||
return [...this.#queries.values()]
|
||||
}
|
||||
|
||||
find<TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData>(
|
||||
filters: WithRequired<QueryFilters, 'queryKey'>,
|
||||
): Query<TQueryFnData, TError, TData> | undefined {
|
||||
const defaultedFilters = { exact: true, ...filters }
|
||||
|
||||
return this.getAll().find((query) =>
|
||||
matchQuery(defaultedFilters, query),
|
||||
) as Query<TQueryFnData, TError, TData> | undefined
|
||||
}
|
||||
|
||||
findAll(filters: QueryFilters<any> = {}): Array<Query> {
|
||||
const queries = this.getAll()
|
||||
return Object.keys(filters).length > 0
|
||||
? queries.filter((query) => matchQuery(filters, query))
|
||||
: queries
|
||||
}
|
||||
|
||||
notify(event: QueryCacheNotifyEvent): void {
|
||||
notifyManager.batch(() => {
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
notifyManager.batch(() => {
|
||||
this.getAll().forEach((query) => {
|
||||
query.onFocus()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onOnline(): void {
|
||||
notifyManager.batch(() => {
|
||||
this.getAll().forEach((query) => {
|
||||
query.onOnline()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
+648
@@ -0,0 +1,648 @@
|
||||
import {
|
||||
functionalUpdate,
|
||||
hashKey,
|
||||
hashQueryKeyByOptions,
|
||||
noop,
|
||||
partialMatchKey,
|
||||
resolveStaleTime,
|
||||
skipToken,
|
||||
} from './utils'
|
||||
import { QueryCache } from './queryCache'
|
||||
import { MutationCache } from './mutationCache'
|
||||
import { focusManager } from './focusManager'
|
||||
import { onlineManager } from './onlineManager'
|
||||
import { notifyManager } from './notifyManager'
|
||||
import { infiniteQueryBehavior } from './infiniteQueryBehavior'
|
||||
import type {
|
||||
CancelOptions,
|
||||
DefaultError,
|
||||
DefaultOptions,
|
||||
DefaultedQueryObserverOptions,
|
||||
EnsureInfiniteQueryDataOptions,
|
||||
EnsureQueryDataOptions,
|
||||
FetchInfiniteQueryOptions,
|
||||
FetchQueryOptions,
|
||||
InferDataFromTag,
|
||||
InferErrorFromTag,
|
||||
InfiniteData,
|
||||
InvalidateOptions,
|
||||
InvalidateQueryFilters,
|
||||
MutationKey,
|
||||
MutationObserverOptions,
|
||||
MutationOptions,
|
||||
NoInfer,
|
||||
OmitKeyof,
|
||||
QueryClientConfig,
|
||||
QueryKey,
|
||||
QueryObserverOptions,
|
||||
QueryOptions,
|
||||
RefetchOptions,
|
||||
RefetchQueryFilters,
|
||||
ResetOptions,
|
||||
SetDataOptions,
|
||||
} from './types'
|
||||
import type { QueryState } from './query'
|
||||
import type { MutationFilters, QueryFilters, Updater } from './utils'
|
||||
|
||||
// TYPES
|
||||
|
||||
interface QueryDefaults {
|
||||
queryKey: QueryKey
|
||||
defaultOptions: OmitKeyof<QueryOptions<any, any, any>, 'queryKey'>
|
||||
}
|
||||
|
||||
interface MutationDefaults {
|
||||
mutationKey: MutationKey
|
||||
defaultOptions: MutationOptions<any, any, any, any>
|
||||
}
|
||||
|
||||
// CLASS
|
||||
|
||||
export class QueryClient {
|
||||
#queryCache: QueryCache
|
||||
#mutationCache: MutationCache
|
||||
#defaultOptions: DefaultOptions
|
||||
#queryDefaults: Map<string, QueryDefaults>
|
||||
#mutationDefaults: Map<string, MutationDefaults>
|
||||
#mountCount: number
|
||||
#unsubscribeFocus?: () => void
|
||||
#unsubscribeOnline?: () => void
|
||||
|
||||
constructor(config: QueryClientConfig = {}) {
|
||||
this.#queryCache = config.queryCache || new QueryCache()
|
||||
this.#mutationCache = config.mutationCache || new MutationCache()
|
||||
this.#defaultOptions = config.defaultOptions || {}
|
||||
this.#queryDefaults = new Map()
|
||||
this.#mutationDefaults = new Map()
|
||||
this.#mountCount = 0
|
||||
}
|
||||
|
||||
mount(): void {
|
||||
this.#mountCount++
|
||||
if (this.#mountCount !== 1) return
|
||||
|
||||
this.#unsubscribeFocus = focusManager.subscribe(async (focused) => {
|
||||
if (focused) {
|
||||
await this.resumePausedMutations()
|
||||
this.#queryCache.onFocus()
|
||||
}
|
||||
})
|
||||
this.#unsubscribeOnline = onlineManager.subscribe(async (online) => {
|
||||
if (online) {
|
||||
await this.resumePausedMutations()
|
||||
this.#queryCache.onOnline()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
this.#mountCount--
|
||||
if (this.#mountCount !== 0) return
|
||||
|
||||
this.#unsubscribeFocus?.()
|
||||
this.#unsubscribeFocus = undefined
|
||||
|
||||
this.#unsubscribeOnline?.()
|
||||
this.#unsubscribeOnline = undefined
|
||||
}
|
||||
|
||||
isFetching<TQueryFilters extends QueryFilters<any> = QueryFilters>(
|
||||
filters?: TQueryFilters,
|
||||
): number {
|
||||
return this.#queryCache.findAll({ ...filters, fetchStatus: 'fetching' })
|
||||
.length
|
||||
}
|
||||
|
||||
isMutating<
|
||||
TMutationFilters extends MutationFilters<any, any> = MutationFilters,
|
||||
>(filters?: TMutationFilters): number {
|
||||
return this.#mutationCache.findAll({ ...filters, status: 'pending' }).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperative (non-reactive) way to retrieve data for a QueryKey.
|
||||
* Should only be used in callbacks or functions where reading the latest data is necessary, e.g. for optimistic updates.
|
||||
*
|
||||
* Hint: Do not use this function inside a component, because it won't receive updates.
|
||||
* Use `useQuery` to create a `QueryObserver` that subscribes to changes.
|
||||
*/
|
||||
getQueryData<
|
||||
TQueryFnData = unknown,
|
||||
TTaggedQueryKey extends QueryKey = QueryKey,
|
||||
TInferredQueryFnData = InferDataFromTag<TQueryFnData, TTaggedQueryKey>,
|
||||
>(queryKey: TTaggedQueryKey): TInferredQueryFnData | undefined {
|
||||
const options = this.defaultQueryOptions({ queryKey })
|
||||
|
||||
return this.#queryCache.get<TInferredQueryFnData>(options.queryHash)?.state
|
||||
.data
|
||||
}
|
||||
|
||||
ensureQueryData<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: EnsureQueryDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): Promise<TData> {
|
||||
const defaultedOptions = this.defaultQueryOptions(options)
|
||||
const query = this.#queryCache.build(this, defaultedOptions)
|
||||
const cachedData = query.state.data
|
||||
|
||||
if (cachedData === undefined) {
|
||||
return this.fetchQuery(options)
|
||||
}
|
||||
|
||||
if (
|
||||
options.revalidateIfStale &&
|
||||
query.isStaleByTime(resolveStaleTime(defaultedOptions.staleTime, query))
|
||||
) {
|
||||
void this.prefetchQuery(defaultedOptions)
|
||||
}
|
||||
|
||||
return Promise.resolve(cachedData)
|
||||
}
|
||||
|
||||
getQueriesData<
|
||||
TQueryFnData = unknown,
|
||||
TQueryFilters extends QueryFilters<any> = QueryFilters,
|
||||
>(filters: TQueryFilters): Array<[QueryKey, TQueryFnData | undefined]> {
|
||||
return this.#queryCache.findAll(filters).map(({ queryKey, state }) => {
|
||||
const data = state.data as TQueryFnData | undefined
|
||||
return [queryKey, data]
|
||||
})
|
||||
}
|
||||
|
||||
setQueryData<
|
||||
TQueryFnData = unknown,
|
||||
TTaggedQueryKey extends QueryKey = QueryKey,
|
||||
TInferredQueryFnData = InferDataFromTag<TQueryFnData, TTaggedQueryKey>,
|
||||
>(
|
||||
queryKey: TTaggedQueryKey,
|
||||
updater: Updater<
|
||||
NoInfer<TInferredQueryFnData> | undefined,
|
||||
NoInfer<TInferredQueryFnData> | undefined
|
||||
>,
|
||||
options?: SetDataOptions,
|
||||
): NoInfer<TInferredQueryFnData> | undefined {
|
||||
const defaultedOptions = this.defaultQueryOptions<
|
||||
any,
|
||||
any,
|
||||
unknown,
|
||||
any,
|
||||
QueryKey
|
||||
>({ queryKey })
|
||||
|
||||
const query = this.#queryCache.get<TInferredQueryFnData>(
|
||||
defaultedOptions.queryHash,
|
||||
)
|
||||
const prevData = query?.state.data
|
||||
const data = functionalUpdate(updater, prevData)
|
||||
|
||||
if (data === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.#queryCache
|
||||
.build(this, defaultedOptions)
|
||||
.setData(data, { ...options, manual: true })
|
||||
}
|
||||
|
||||
setQueriesData<
|
||||
TQueryFnData,
|
||||
TQueryFilters extends QueryFilters<any> = QueryFilters,
|
||||
>(
|
||||
filters: TQueryFilters,
|
||||
updater: Updater<
|
||||
NoInfer<TQueryFnData> | undefined,
|
||||
NoInfer<TQueryFnData> | undefined
|
||||
>,
|
||||
options?: SetDataOptions,
|
||||
): Array<[QueryKey, TQueryFnData | undefined]> {
|
||||
return notifyManager.batch(() =>
|
||||
this.#queryCache
|
||||
.findAll(filters)
|
||||
.map(({ queryKey }) => [
|
||||
queryKey,
|
||||
this.setQueryData<TQueryFnData>(queryKey, updater, options),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
getQueryState<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TTaggedQueryKey extends QueryKey = QueryKey,
|
||||
TInferredQueryFnData = InferDataFromTag<TQueryFnData, TTaggedQueryKey>,
|
||||
TInferredError = InferErrorFromTag<TError, TTaggedQueryKey>,
|
||||
>(
|
||||
queryKey: TTaggedQueryKey,
|
||||
): QueryState<TInferredQueryFnData, TInferredError> | undefined {
|
||||
const options = this.defaultQueryOptions({ queryKey })
|
||||
return this.#queryCache.get<TInferredQueryFnData, TInferredError>(
|
||||
options.queryHash,
|
||||
)?.state
|
||||
}
|
||||
|
||||
removeQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
|
||||
filters?: QueryFilters<TTaggedQueryKey>,
|
||||
): void {
|
||||
const queryCache = this.#queryCache
|
||||
notifyManager.batch(() => {
|
||||
queryCache.findAll(filters).forEach((query) => {
|
||||
queryCache.remove(query)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
resetQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
|
||||
filters?: QueryFilters<TTaggedQueryKey>,
|
||||
options?: ResetOptions,
|
||||
): Promise<void> {
|
||||
const queryCache = this.#queryCache
|
||||
|
||||
return notifyManager.batch(() => {
|
||||
queryCache.findAll(filters).forEach((query) => {
|
||||
query.reset()
|
||||
})
|
||||
return this.refetchQueries(
|
||||
{
|
||||
type: 'active',
|
||||
...filters,
|
||||
},
|
||||
options,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
cancelQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
|
||||
filters?: QueryFilters<TTaggedQueryKey>,
|
||||
cancelOptions: CancelOptions = {},
|
||||
): Promise<void> {
|
||||
const defaultedCancelOptions = { revert: true, ...cancelOptions }
|
||||
|
||||
const promises = notifyManager.batch(() =>
|
||||
this.#queryCache
|
||||
.findAll(filters)
|
||||
.map((query) => query.cancel(defaultedCancelOptions)),
|
||||
)
|
||||
|
||||
return Promise.all(promises).then(noop).catch(noop)
|
||||
}
|
||||
|
||||
invalidateQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
|
||||
filters?: InvalidateQueryFilters<TTaggedQueryKey>,
|
||||
options: InvalidateOptions = {},
|
||||
): Promise<void> {
|
||||
return notifyManager.batch(() => {
|
||||
this.#queryCache.findAll(filters).forEach((query) => {
|
||||
query.invalidate()
|
||||
})
|
||||
|
||||
if (filters?.refetchType === 'none') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return this.refetchQueries(
|
||||
{
|
||||
...filters,
|
||||
type: filters?.refetchType ?? filters?.type ?? 'active',
|
||||
},
|
||||
options,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
refetchQueries<TTaggedQueryKey extends QueryKey = QueryKey>(
|
||||
filters?: RefetchQueryFilters<TTaggedQueryKey>,
|
||||
options: RefetchOptions = {},
|
||||
): Promise<void> {
|
||||
const fetchOptions = {
|
||||
...options,
|
||||
cancelRefetch: options.cancelRefetch ?? true,
|
||||
}
|
||||
const promises = notifyManager.batch(() =>
|
||||
this.#queryCache
|
||||
.findAll(filters)
|
||||
.filter((query) => !query.isDisabled() && !query.isStatic())
|
||||
.map((query) => {
|
||||
let promise = query.fetch(undefined, fetchOptions)
|
||||
if (!fetchOptions.throwOnError) {
|
||||
promise = promise.catch(noop)
|
||||
}
|
||||
return query.state.fetchStatus === 'paused'
|
||||
? Promise.resolve()
|
||||
: promise
|
||||
}),
|
||||
)
|
||||
|
||||
return Promise.all(promises).then(noop)
|
||||
}
|
||||
|
||||
fetchQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = never,
|
||||
>(
|
||||
options: FetchQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): Promise<TData> {
|
||||
const defaultedOptions = this.defaultQueryOptions(options)
|
||||
|
||||
// https://github.com/tannerlinsley/react-query/issues/652
|
||||
if (defaultedOptions.retry === undefined) {
|
||||
defaultedOptions.retry = false
|
||||
}
|
||||
|
||||
const query = this.#queryCache.build(this, defaultedOptions)
|
||||
|
||||
return query.isStaleByTime(
|
||||
resolveStaleTime(defaultedOptions.staleTime, query),
|
||||
)
|
||||
? query.fetch(defaultedOptions)
|
||||
: Promise.resolve(query.state.data as TData)
|
||||
}
|
||||
|
||||
prefetchQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): Promise<void> {
|
||||
return this.fetchQuery(options).then(noop).catch(noop)
|
||||
}
|
||||
|
||||
fetchInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: FetchInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): Promise<InfiniteData<TData, TPageParam>> {
|
||||
options.behavior = infiniteQueryBehavior<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TPageParam
|
||||
>(options.pages)
|
||||
return this.fetchQuery(options as any)
|
||||
}
|
||||
|
||||
prefetchInfiniteQuery<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: FetchInfiniteQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): Promise<void> {
|
||||
return this.fetchInfiniteQuery(options).then(noop).catch(noop)
|
||||
}
|
||||
|
||||
ensureInfiniteQueryData<
|
||||
TQueryFnData,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = unknown,
|
||||
>(
|
||||
options: EnsureInfiniteQueryDataOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>,
|
||||
): Promise<InfiniteData<TData, TPageParam>> {
|
||||
options.behavior = infiniteQueryBehavior<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TPageParam
|
||||
>(options.pages)
|
||||
|
||||
return this.ensureQueryData(options as any)
|
||||
}
|
||||
|
||||
resumePausedMutations(): Promise<unknown> {
|
||||
if (onlineManager.isOnline()) {
|
||||
return this.#mutationCache.resumePausedMutations()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
getQueryCache(): QueryCache {
|
||||
return this.#queryCache
|
||||
}
|
||||
|
||||
getMutationCache(): MutationCache {
|
||||
return this.#mutationCache
|
||||
}
|
||||
|
||||
getDefaultOptions(): DefaultOptions {
|
||||
return this.#defaultOptions
|
||||
}
|
||||
|
||||
setDefaultOptions(options: DefaultOptions): void {
|
||||
this.#defaultOptions = options
|
||||
}
|
||||
|
||||
setQueryDefaults<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
>(
|
||||
queryKey: QueryKey,
|
||||
options: Partial<
|
||||
OmitKeyof<
|
||||
QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>,
|
||||
'queryKey'
|
||||
>
|
||||
>,
|
||||
): void {
|
||||
this.#queryDefaults.set(hashKey(queryKey), {
|
||||
queryKey,
|
||||
defaultOptions: options,
|
||||
})
|
||||
}
|
||||
|
||||
getQueryDefaults(
|
||||
queryKey: QueryKey,
|
||||
): OmitKeyof<QueryObserverOptions<any, any, any, any, any>, 'queryKey'> {
|
||||
const defaults = [...this.#queryDefaults.values()]
|
||||
|
||||
const result: OmitKeyof<
|
||||
QueryObserverOptions<any, any, any, any, any>,
|
||||
'queryKey'
|
||||
> = {}
|
||||
|
||||
defaults.forEach((queryDefault) => {
|
||||
if (partialMatchKey(queryKey, queryDefault.queryKey)) {
|
||||
Object.assign(result, queryDefault.defaultOptions)
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
setMutationDefaults<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = void,
|
||||
TOnMutateResult = unknown,
|
||||
>(
|
||||
mutationKey: MutationKey,
|
||||
options: OmitKeyof<
|
||||
MutationObserverOptions<TData, TError, TVariables, TOnMutateResult>,
|
||||
'mutationKey'
|
||||
>,
|
||||
): void {
|
||||
this.#mutationDefaults.set(hashKey(mutationKey), {
|
||||
mutationKey,
|
||||
defaultOptions: options,
|
||||
})
|
||||
}
|
||||
|
||||
getMutationDefaults(
|
||||
mutationKey: MutationKey,
|
||||
): OmitKeyof<MutationObserverOptions<any, any, any, any>, 'mutationKey'> {
|
||||
const defaults = [...this.#mutationDefaults.values()]
|
||||
|
||||
const result: OmitKeyof<
|
||||
MutationObserverOptions<any, any, any, any>,
|
||||
'mutationKey'
|
||||
> = {}
|
||||
|
||||
defaults.forEach((queryDefault) => {
|
||||
if (partialMatchKey(mutationKey, queryDefault.mutationKey)) {
|
||||
Object.assign(result, queryDefault.defaultOptions)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
defaultQueryOptions<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
TPageParam = never,
|
||||
>(
|
||||
options:
|
||||
| QueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey,
|
||||
TPageParam
|
||||
>
|
||||
| DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
): DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
> {
|
||||
if (options._defaulted) {
|
||||
return options as DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>
|
||||
}
|
||||
|
||||
const defaultedOptions = {
|
||||
...this.#defaultOptions.queries,
|
||||
...this.getQueryDefaults(options.queryKey),
|
||||
...options,
|
||||
_defaulted: true,
|
||||
}
|
||||
|
||||
if (!defaultedOptions.queryHash) {
|
||||
defaultedOptions.queryHash = hashQueryKeyByOptions(
|
||||
defaultedOptions.queryKey,
|
||||
defaultedOptions,
|
||||
)
|
||||
}
|
||||
|
||||
// dependent default values
|
||||
if (defaultedOptions.refetchOnReconnect === undefined) {
|
||||
defaultedOptions.refetchOnReconnect =
|
||||
defaultedOptions.networkMode !== 'always'
|
||||
}
|
||||
if (defaultedOptions.throwOnError === undefined) {
|
||||
defaultedOptions.throwOnError = !!defaultedOptions.suspense
|
||||
}
|
||||
|
||||
if (!defaultedOptions.networkMode && defaultedOptions.persister) {
|
||||
defaultedOptions.networkMode = 'offlineFirst'
|
||||
}
|
||||
|
||||
if (defaultedOptions.queryFn === skipToken) {
|
||||
defaultedOptions.enabled = false
|
||||
}
|
||||
|
||||
return defaultedOptions as DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>
|
||||
}
|
||||
|
||||
defaultMutationOptions<T extends MutationOptions<any, any, any, any>>(
|
||||
options?: T,
|
||||
): T {
|
||||
if (options?._defaulted) {
|
||||
return options
|
||||
}
|
||||
return {
|
||||
...this.#defaultOptions.mutations,
|
||||
...(options?.mutationKey &&
|
||||
this.getMutationDefaults(options.mutationKey)),
|
||||
...options,
|
||||
_defaulted: true,
|
||||
} as T
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#queryCache.clear()
|
||||
this.#mutationCache.clear()
|
||||
}
|
||||
}
|
||||
+828
@@ -0,0 +1,828 @@
|
||||
import { focusManager } from './focusManager'
|
||||
import { notifyManager } from './notifyManager'
|
||||
import { fetchState } from './query'
|
||||
import { Subscribable } from './subscribable'
|
||||
import { pendingThenable } from './thenable'
|
||||
import {
|
||||
isServer,
|
||||
isValidTimeout,
|
||||
noop,
|
||||
replaceData,
|
||||
resolveEnabled,
|
||||
resolveStaleTime,
|
||||
shallowEqualObjects,
|
||||
timeUntilStale,
|
||||
} from './utils'
|
||||
import { timeoutManager } from './timeoutManager'
|
||||
import type { ManagedTimerId } from './timeoutManager'
|
||||
import type { FetchOptions, Query, QueryState } from './query'
|
||||
import type { QueryClient } from './queryClient'
|
||||
import type { PendingThenable, Thenable } from './thenable'
|
||||
import type {
|
||||
DefaultError,
|
||||
DefaultedQueryObserverOptions,
|
||||
PlaceholderDataFunction,
|
||||
QueryKey,
|
||||
QueryObserverBaseResult,
|
||||
QueryObserverOptions,
|
||||
QueryObserverResult,
|
||||
RefetchOptions,
|
||||
} from './types'
|
||||
|
||||
type QueryObserverListener<TData, TError> = (
|
||||
result: QueryObserverResult<TData, TError>,
|
||||
) => void
|
||||
|
||||
interface ObserverFetchOptions extends FetchOptions {
|
||||
throwOnError?: boolean
|
||||
}
|
||||
|
||||
export class QueryObserver<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
> extends Subscribable<QueryObserverListener<TData, TError>> {
|
||||
#client: QueryClient
|
||||
#currentQuery: Query<TQueryFnData, TError, TQueryData, TQueryKey> = undefined!
|
||||
#currentQueryInitialState: QueryState<TQueryData, TError> = undefined!
|
||||
#currentResult: QueryObserverResult<TData, TError> = undefined!
|
||||
#currentResultState?: QueryState<TQueryData, TError>
|
||||
#currentResultOptions?: QueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>
|
||||
#currentThenable: Thenable<TData>
|
||||
#selectError: TError | null
|
||||
#selectFn?: (data: TQueryData) => TData
|
||||
#selectResult?: TData
|
||||
// This property keeps track of the last query with defined data.
|
||||
// It will be used to pass the previous data and query to the placeholder function between renders.
|
||||
#lastQueryWithDefinedData?: Query<TQueryFnData, TError, TQueryData, TQueryKey>
|
||||
#staleTimeoutId?: ManagedTimerId
|
||||
#refetchIntervalId?: ManagedTimerId
|
||||
#currentRefetchInterval?: number | false
|
||||
#trackedProps = new Set<keyof QueryObserverResult>()
|
||||
|
||||
constructor(
|
||||
client: QueryClient,
|
||||
public options: QueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
) {
|
||||
super()
|
||||
|
||||
this.#client = client
|
||||
this.#selectError = null
|
||||
this.#currentThenable = pendingThenable()
|
||||
|
||||
this.bindMethods()
|
||||
this.setOptions(options)
|
||||
}
|
||||
|
||||
protected bindMethods(): void {
|
||||
this.refetch = this.refetch.bind(this)
|
||||
}
|
||||
|
||||
protected onSubscribe(): void {
|
||||
if (this.listeners.size === 1) {
|
||||
this.#currentQuery.addObserver(this)
|
||||
|
||||
if (shouldFetchOnMount(this.#currentQuery, this.options)) {
|
||||
this.#executeFetch()
|
||||
} else {
|
||||
this.updateResult()
|
||||
}
|
||||
|
||||
this.#updateTimers()
|
||||
}
|
||||
}
|
||||
|
||||
protected onUnsubscribe(): void {
|
||||
if (!this.hasListeners()) {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
shouldFetchOnReconnect(): boolean {
|
||||
return shouldFetchOn(
|
||||
this.#currentQuery,
|
||||
this.options,
|
||||
this.options.refetchOnReconnect,
|
||||
)
|
||||
}
|
||||
|
||||
shouldFetchOnWindowFocus(): boolean {
|
||||
return shouldFetchOn(
|
||||
this.#currentQuery,
|
||||
this.options,
|
||||
this.options.refetchOnWindowFocus,
|
||||
)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.listeners = new Set()
|
||||
this.#clearStaleTimeout()
|
||||
this.#clearRefetchInterval()
|
||||
this.#currentQuery.removeObserver(this)
|
||||
}
|
||||
|
||||
setOptions(
|
||||
options: QueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
): void {
|
||||
const prevOptions = this.options
|
||||
const prevQuery = this.#currentQuery
|
||||
|
||||
this.options = this.#client.defaultQueryOptions(options)
|
||||
|
||||
if (
|
||||
this.options.enabled !== undefined &&
|
||||
typeof this.options.enabled !== 'boolean' &&
|
||||
typeof this.options.enabled !== 'function' &&
|
||||
typeof resolveEnabled(this.options.enabled, this.#currentQuery) !==
|
||||
'boolean'
|
||||
) {
|
||||
throw new Error(
|
||||
'Expected enabled to be a boolean or a callback that returns a boolean',
|
||||
)
|
||||
}
|
||||
|
||||
this.#updateQuery()
|
||||
this.#currentQuery.setOptions(this.options)
|
||||
|
||||
if (
|
||||
prevOptions._defaulted &&
|
||||
!shallowEqualObjects(this.options, prevOptions)
|
||||
) {
|
||||
this.#client.getQueryCache().notify({
|
||||
type: 'observerOptionsUpdated',
|
||||
query: this.#currentQuery,
|
||||
observer: this,
|
||||
})
|
||||
}
|
||||
|
||||
const mounted = this.hasListeners()
|
||||
|
||||
// Fetch if there are subscribers
|
||||
if (
|
||||
mounted &&
|
||||
shouldFetchOptionally(
|
||||
this.#currentQuery,
|
||||
prevQuery,
|
||||
this.options,
|
||||
prevOptions,
|
||||
)
|
||||
) {
|
||||
this.#executeFetch()
|
||||
}
|
||||
|
||||
// Update result
|
||||
this.updateResult()
|
||||
|
||||
// Update stale interval if needed
|
||||
if (
|
||||
mounted &&
|
||||
(this.#currentQuery !== prevQuery ||
|
||||
resolveEnabled(this.options.enabled, this.#currentQuery) !==
|
||||
resolveEnabled(prevOptions.enabled, this.#currentQuery) ||
|
||||
resolveStaleTime(this.options.staleTime, this.#currentQuery) !==
|
||||
resolveStaleTime(prevOptions.staleTime, this.#currentQuery))
|
||||
) {
|
||||
this.#updateStaleTimeout()
|
||||
}
|
||||
|
||||
const nextRefetchInterval = this.#computeRefetchInterval()
|
||||
|
||||
// Update refetch interval if needed
|
||||
if (
|
||||
mounted &&
|
||||
(this.#currentQuery !== prevQuery ||
|
||||
resolveEnabled(this.options.enabled, this.#currentQuery) !==
|
||||
resolveEnabled(prevOptions.enabled, this.#currentQuery) ||
|
||||
nextRefetchInterval !== this.#currentRefetchInterval)
|
||||
) {
|
||||
this.#updateRefetchInterval(nextRefetchInterval)
|
||||
}
|
||||
}
|
||||
|
||||
getOptimisticResult(
|
||||
options: DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
): QueryObserverResult<TData, TError> {
|
||||
const query = this.#client.getQueryCache().build(this.#client, options)
|
||||
|
||||
const result = this.createResult(query, options)
|
||||
|
||||
if (shouldAssignObserverCurrentProperties(this, result)) {
|
||||
// this assigns the optimistic result to the current Observer
|
||||
// because if the query function changes, useQuery will be performing
|
||||
// an effect where it would fetch again.
|
||||
// When the fetch finishes, we perform a deep data cloning in order
|
||||
// to reuse objects references. This deep data clone is performed against
|
||||
// the `observer.currentResult.data` property
|
||||
// When QueryKey changes, we refresh the query and get new `optimistic`
|
||||
// result, while we leave the `observer.currentResult`, so when new data
|
||||
// arrives, it finds the old `observer.currentResult` which is related
|
||||
// to the old QueryKey. Which means that currentResult and selectData are
|
||||
// out of sync already.
|
||||
// To solve this, we move the cursor of the currentResult every time
|
||||
// an observer reads an optimistic value.
|
||||
|
||||
// When keeping the previous data, the result doesn't change until new
|
||||
// data arrives.
|
||||
this.#currentResult = result
|
||||
this.#currentResultOptions = this.options
|
||||
this.#currentResultState = this.#currentQuery.state
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
getCurrentResult(): QueryObserverResult<TData, TError> {
|
||||
return this.#currentResult
|
||||
}
|
||||
|
||||
trackResult(
|
||||
result: QueryObserverResult<TData, TError>,
|
||||
onPropTracked?: (key: keyof QueryObserverResult) => void,
|
||||
): QueryObserverResult<TData, TError> {
|
||||
return new Proxy(result, {
|
||||
get: (target, key) => {
|
||||
this.trackProp(key as keyof QueryObserverResult)
|
||||
onPropTracked?.(key as keyof QueryObserverResult)
|
||||
if (key === 'promise') {
|
||||
this.trackProp('data')
|
||||
if (
|
||||
!this.options.experimental_prefetchInRender &&
|
||||
this.#currentThenable.status === 'pending'
|
||||
) {
|
||||
this.#currentThenable.reject(
|
||||
new Error(
|
||||
'experimental_prefetchInRender feature flag is not enabled',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
return Reflect.get(target, key)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
trackProp(key: keyof QueryObserverResult) {
|
||||
this.#trackedProps.add(key)
|
||||
}
|
||||
|
||||
getCurrentQuery(): Query<TQueryFnData, TError, TQueryData, TQueryKey> {
|
||||
return this.#currentQuery
|
||||
}
|
||||
|
||||
refetch({ ...options }: RefetchOptions = {}): Promise<
|
||||
QueryObserverResult<TData, TError>
|
||||
> {
|
||||
return this.fetch({
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
fetchOptimistic(
|
||||
options: QueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
): Promise<QueryObserverResult<TData, TError>> {
|
||||
const defaultedOptions = this.#client.defaultQueryOptions(options)
|
||||
|
||||
const query = this.#client
|
||||
.getQueryCache()
|
||||
.build(this.#client, defaultedOptions)
|
||||
|
||||
return query.fetch().then(() => this.createResult(query, defaultedOptions))
|
||||
}
|
||||
|
||||
protected fetch(
|
||||
fetchOptions: ObserverFetchOptions,
|
||||
): Promise<QueryObserverResult<TData, TError>> {
|
||||
return this.#executeFetch({
|
||||
...fetchOptions,
|
||||
cancelRefetch: fetchOptions.cancelRefetch ?? true,
|
||||
}).then(() => {
|
||||
this.updateResult()
|
||||
return this.#currentResult
|
||||
})
|
||||
}
|
||||
|
||||
#executeFetch(
|
||||
fetchOptions?: Omit<ObserverFetchOptions, 'initialPromise'>,
|
||||
): Promise<TQueryData | undefined> {
|
||||
// Make sure we reference the latest query as the current one might have been removed
|
||||
this.#updateQuery()
|
||||
|
||||
// Fetch
|
||||
let promise: Promise<TQueryData | undefined> = this.#currentQuery.fetch(
|
||||
this.options,
|
||||
fetchOptions,
|
||||
)
|
||||
|
||||
if (!fetchOptions?.throwOnError) {
|
||||
promise = promise.catch(noop)
|
||||
}
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
#updateStaleTimeout(): void {
|
||||
this.#clearStaleTimeout()
|
||||
const staleTime = resolveStaleTime(
|
||||
this.options.staleTime,
|
||||
this.#currentQuery,
|
||||
)
|
||||
|
||||
if (isServer || this.#currentResult.isStale || !isValidTimeout(staleTime)) {
|
||||
return
|
||||
}
|
||||
|
||||
const time = timeUntilStale(this.#currentResult.dataUpdatedAt, staleTime)
|
||||
|
||||
// The timeout is sometimes triggered 1 ms before the stale time expiration.
|
||||
// To mitigate this issue we always add 1 ms to the timeout.
|
||||
const timeout = time + 1
|
||||
|
||||
this.#staleTimeoutId = timeoutManager.setTimeout(() => {
|
||||
if (!this.#currentResult.isStale) {
|
||||
this.updateResult()
|
||||
}
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
#computeRefetchInterval() {
|
||||
return (
|
||||
(typeof this.options.refetchInterval === 'function'
|
||||
? this.options.refetchInterval(this.#currentQuery)
|
||||
: this.options.refetchInterval) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
#updateRefetchInterval(nextInterval: number | false): void {
|
||||
this.#clearRefetchInterval()
|
||||
|
||||
this.#currentRefetchInterval = nextInterval
|
||||
|
||||
if (
|
||||
isServer ||
|
||||
resolveEnabled(this.options.enabled, this.#currentQuery) === false ||
|
||||
!isValidTimeout(this.#currentRefetchInterval) ||
|
||||
this.#currentRefetchInterval === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#refetchIntervalId = timeoutManager.setInterval(() => {
|
||||
if (
|
||||
this.options.refetchIntervalInBackground ||
|
||||
focusManager.isFocused()
|
||||
) {
|
||||
this.#executeFetch()
|
||||
}
|
||||
}, this.#currentRefetchInterval)
|
||||
}
|
||||
|
||||
#updateTimers(): void {
|
||||
this.#updateStaleTimeout()
|
||||
this.#updateRefetchInterval(this.#computeRefetchInterval())
|
||||
}
|
||||
|
||||
#clearStaleTimeout(): void {
|
||||
if (this.#staleTimeoutId) {
|
||||
timeoutManager.clearTimeout(this.#staleTimeoutId)
|
||||
this.#staleTimeoutId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
#clearRefetchInterval(): void {
|
||||
if (this.#refetchIntervalId) {
|
||||
timeoutManager.clearInterval(this.#refetchIntervalId)
|
||||
this.#refetchIntervalId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
protected createResult(
|
||||
query: Query<TQueryFnData, TError, TQueryData, TQueryKey>,
|
||||
options: QueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
): QueryObserverResult<TData, TError> {
|
||||
const prevQuery = this.#currentQuery
|
||||
const prevOptions = this.options
|
||||
const prevResult = this.#currentResult as
|
||||
| QueryObserverResult<TData, TError>
|
||||
| undefined
|
||||
const prevResultState = this.#currentResultState
|
||||
const prevResultOptions = this.#currentResultOptions
|
||||
const queryChange = query !== prevQuery
|
||||
const queryInitialState = queryChange
|
||||
? query.state
|
||||
: this.#currentQueryInitialState
|
||||
|
||||
const { state } = query
|
||||
let newState = { ...state }
|
||||
let isPlaceholderData = false
|
||||
let data: TData | undefined
|
||||
|
||||
// Optimistically set result in fetching state if needed
|
||||
if (options._optimisticResults) {
|
||||
const mounted = this.hasListeners()
|
||||
|
||||
const fetchOnMount = !mounted && shouldFetchOnMount(query, options)
|
||||
|
||||
const fetchOptionally =
|
||||
mounted && shouldFetchOptionally(query, prevQuery, options, prevOptions)
|
||||
|
||||
if (fetchOnMount || fetchOptionally) {
|
||||
newState = {
|
||||
...newState,
|
||||
...fetchState(state.data, query.options),
|
||||
}
|
||||
}
|
||||
if (options._optimisticResults === 'isRestoring') {
|
||||
newState.fetchStatus = 'idle'
|
||||
}
|
||||
}
|
||||
|
||||
let { error, errorUpdatedAt, status } = newState
|
||||
|
||||
// Per default, use query data
|
||||
data = newState.data as unknown as TData
|
||||
let skipSelect = false
|
||||
|
||||
// use placeholderData if needed
|
||||
if (
|
||||
options.placeholderData !== undefined &&
|
||||
data === undefined &&
|
||||
status === 'pending'
|
||||
) {
|
||||
let placeholderData
|
||||
|
||||
// Memoize placeholder data
|
||||
if (
|
||||
prevResult?.isPlaceholderData &&
|
||||
options.placeholderData === prevResultOptions?.placeholderData
|
||||
) {
|
||||
placeholderData = prevResult.data
|
||||
// we have to skip select when reading this memoization
|
||||
// because prevResult.data is already "selected"
|
||||
skipSelect = true
|
||||
} else {
|
||||
// compute placeholderData
|
||||
placeholderData =
|
||||
typeof options.placeholderData === 'function'
|
||||
? (
|
||||
options.placeholderData as unknown as PlaceholderDataFunction<TQueryData>
|
||||
)(
|
||||
this.#lastQueryWithDefinedData?.state.data,
|
||||
this.#lastQueryWithDefinedData as any,
|
||||
)
|
||||
: options.placeholderData
|
||||
}
|
||||
|
||||
if (placeholderData !== undefined) {
|
||||
status = 'success'
|
||||
data = replaceData(
|
||||
prevResult?.data,
|
||||
placeholderData as unknown,
|
||||
options,
|
||||
) as TData
|
||||
isPlaceholderData = true
|
||||
}
|
||||
}
|
||||
|
||||
// Select data if needed
|
||||
// this also runs placeholderData through the select function
|
||||
if (options.select && data !== undefined && !skipSelect) {
|
||||
// Memoize select result
|
||||
if (
|
||||
prevResult &&
|
||||
data === prevResultState?.data &&
|
||||
options.select === this.#selectFn
|
||||
) {
|
||||
data = this.#selectResult
|
||||
} else {
|
||||
try {
|
||||
this.#selectFn = options.select
|
||||
data = options.select(data as any)
|
||||
data = replaceData(prevResult?.data, data, options)
|
||||
this.#selectResult = data
|
||||
this.#selectError = null
|
||||
} catch (selectError) {
|
||||
this.#selectError = selectError as TError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#selectError) {
|
||||
error = this.#selectError
|
||||
data = this.#selectResult
|
||||
errorUpdatedAt = Date.now()
|
||||
status = 'error'
|
||||
}
|
||||
|
||||
const isFetching = newState.fetchStatus === 'fetching'
|
||||
const isPending = status === 'pending'
|
||||
const isError = status === 'error'
|
||||
|
||||
const isLoading = isPending && isFetching
|
||||
const hasData = data !== undefined
|
||||
|
||||
const result: QueryObserverBaseResult<TData, TError> = {
|
||||
status,
|
||||
fetchStatus: newState.fetchStatus,
|
||||
isPending,
|
||||
isSuccess: status === 'success',
|
||||
isError,
|
||||
isInitialLoading: isLoading,
|
||||
isLoading,
|
||||
data,
|
||||
dataUpdatedAt: newState.dataUpdatedAt,
|
||||
error,
|
||||
errorUpdatedAt,
|
||||
failureCount: newState.fetchFailureCount,
|
||||
failureReason: newState.fetchFailureReason,
|
||||
errorUpdateCount: newState.errorUpdateCount,
|
||||
isFetched: newState.dataUpdateCount > 0 || newState.errorUpdateCount > 0,
|
||||
isFetchedAfterMount:
|
||||
newState.dataUpdateCount > queryInitialState.dataUpdateCount ||
|
||||
newState.errorUpdateCount > queryInitialState.errorUpdateCount,
|
||||
isFetching,
|
||||
isRefetching: isFetching && !isPending,
|
||||
isLoadingError: isError && !hasData,
|
||||
isPaused: newState.fetchStatus === 'paused',
|
||||
isPlaceholderData,
|
||||
isRefetchError: isError && hasData,
|
||||
isStale: isStale(query, options),
|
||||
refetch: this.refetch,
|
||||
promise: this.#currentThenable,
|
||||
isEnabled: resolveEnabled(options.enabled, query) !== false,
|
||||
}
|
||||
|
||||
const nextResult = result as QueryObserverResult<TData, TError>
|
||||
|
||||
if (this.options.experimental_prefetchInRender) {
|
||||
const hasResultData = nextResult.data !== undefined
|
||||
const isErrorWithoutData = nextResult.status === 'error' && !hasResultData
|
||||
const finalizeThenableIfPossible = (thenable: PendingThenable<TData>) => {
|
||||
if (isErrorWithoutData) {
|
||||
thenable.reject(nextResult.error)
|
||||
} else if (hasResultData) {
|
||||
thenable.resolve(nextResult.data as TData)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new thenable and result promise when the results have changed
|
||||
*/
|
||||
const recreateThenable = () => {
|
||||
const pending =
|
||||
(this.#currentThenable =
|
||||
nextResult.promise =
|
||||
pendingThenable())
|
||||
|
||||
finalizeThenableIfPossible(pending)
|
||||
}
|
||||
|
||||
const prevThenable = this.#currentThenable
|
||||
switch (prevThenable.status) {
|
||||
case 'pending':
|
||||
// Finalize the previous thenable if it was pending
|
||||
// and we are still observing the same query
|
||||
if (query.queryHash === prevQuery.queryHash) {
|
||||
finalizeThenableIfPossible(prevThenable)
|
||||
}
|
||||
break
|
||||
case 'fulfilled':
|
||||
if (isErrorWithoutData || nextResult.data !== prevThenable.value) {
|
||||
recreateThenable()
|
||||
}
|
||||
break
|
||||
case 'rejected':
|
||||
if (!isErrorWithoutData || nextResult.error !== prevThenable.reason) {
|
||||
recreateThenable()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nextResult
|
||||
}
|
||||
|
||||
updateResult(): void {
|
||||
const prevResult = this.#currentResult as
|
||||
| QueryObserverResult<TData, TError>
|
||||
| undefined
|
||||
|
||||
const nextResult = this.createResult(this.#currentQuery, this.options)
|
||||
|
||||
this.#currentResultState = this.#currentQuery.state
|
||||
this.#currentResultOptions = this.options
|
||||
|
||||
if (this.#currentResultState.data !== undefined) {
|
||||
this.#lastQueryWithDefinedData = this.#currentQuery
|
||||
}
|
||||
|
||||
// Only notify and update result if something has changed
|
||||
if (shallowEqualObjects(nextResult, prevResult)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.#currentResult = nextResult
|
||||
|
||||
const shouldNotifyListeners = (): boolean => {
|
||||
if (!prevResult) {
|
||||
return true
|
||||
}
|
||||
|
||||
const { notifyOnChangeProps } = this.options
|
||||
const notifyOnChangePropsValue =
|
||||
typeof notifyOnChangeProps === 'function'
|
||||
? notifyOnChangeProps()
|
||||
: notifyOnChangeProps
|
||||
|
||||
if (
|
||||
notifyOnChangePropsValue === 'all' ||
|
||||
(!notifyOnChangePropsValue && !this.#trackedProps.size)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const includedProps = new Set(
|
||||
notifyOnChangePropsValue ?? this.#trackedProps,
|
||||
)
|
||||
|
||||
if (this.options.throwOnError) {
|
||||
includedProps.add('error')
|
||||
}
|
||||
|
||||
return Object.keys(this.#currentResult).some((key) => {
|
||||
const typedKey = key as keyof QueryObserverResult
|
||||
const changed = this.#currentResult[typedKey] !== prevResult[typedKey]
|
||||
|
||||
return changed && includedProps.has(typedKey)
|
||||
})
|
||||
}
|
||||
|
||||
this.#notify({ listeners: shouldNotifyListeners() })
|
||||
}
|
||||
|
||||
#updateQuery(): void {
|
||||
const query = this.#client.getQueryCache().build(this.#client, this.options)
|
||||
|
||||
if (query === this.#currentQuery) {
|
||||
return
|
||||
}
|
||||
|
||||
const prevQuery = this.#currentQuery as
|
||||
| Query<TQueryFnData, TError, TQueryData, TQueryKey>
|
||||
| undefined
|
||||
this.#currentQuery = query
|
||||
this.#currentQueryInitialState = query.state
|
||||
|
||||
if (this.hasListeners()) {
|
||||
prevQuery?.removeObserver(this)
|
||||
query.addObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
onQueryUpdate(): void {
|
||||
this.updateResult()
|
||||
|
||||
if (this.hasListeners()) {
|
||||
this.#updateTimers()
|
||||
}
|
||||
}
|
||||
|
||||
#notify(notifyOptions: { listeners: boolean }): void {
|
||||
notifyManager.batch(() => {
|
||||
// First, trigger the listeners
|
||||
if (notifyOptions.listeners) {
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(this.#currentResult)
|
||||
})
|
||||
}
|
||||
|
||||
// Then the cache listeners
|
||||
this.#client.getQueryCache().notify({
|
||||
query: this.#currentQuery,
|
||||
type: 'observerResultsUpdated',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function shouldLoadOnMount(
|
||||
query: Query<any, any, any, any>,
|
||||
options: QueryObserverOptions<any, any, any, any>,
|
||||
): boolean {
|
||||
return (
|
||||
resolveEnabled(options.enabled, query) !== false &&
|
||||
query.state.data === undefined &&
|
||||
!(query.state.status === 'error' && options.retryOnMount === false)
|
||||
)
|
||||
}
|
||||
|
||||
function shouldFetchOnMount(
|
||||
query: Query<any, any, any, any>,
|
||||
options: QueryObserverOptions<any, any, any, any, any>,
|
||||
): boolean {
|
||||
return (
|
||||
shouldLoadOnMount(query, options) ||
|
||||
(query.state.data !== undefined &&
|
||||
shouldFetchOn(query, options, options.refetchOnMount))
|
||||
)
|
||||
}
|
||||
|
||||
function shouldFetchOn(
|
||||
query: Query<any, any, any, any>,
|
||||
options: QueryObserverOptions<any, any, any, any, any>,
|
||||
field: (typeof options)['refetchOnMount'] &
|
||||
(typeof options)['refetchOnWindowFocus'] &
|
||||
(typeof options)['refetchOnReconnect'],
|
||||
) {
|
||||
if (
|
||||
resolveEnabled(options.enabled, query) !== false &&
|
||||
resolveStaleTime(options.staleTime, query) !== 'static'
|
||||
) {
|
||||
const value = typeof field === 'function' ? field(query) : field
|
||||
|
||||
return value === 'always' || (value !== false && isStale(query, options))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function shouldFetchOptionally(
|
||||
query: Query<any, any, any, any>,
|
||||
prevQuery: Query<any, any, any, any>,
|
||||
options: QueryObserverOptions<any, any, any, any, any>,
|
||||
prevOptions: QueryObserverOptions<any, any, any, any, any>,
|
||||
): boolean {
|
||||
return (
|
||||
(query !== prevQuery ||
|
||||
resolveEnabled(prevOptions.enabled, query) === false) &&
|
||||
(!options.suspense || query.state.status !== 'error') &&
|
||||
isStale(query, options)
|
||||
)
|
||||
}
|
||||
|
||||
function isStale(
|
||||
query: Query<any, any, any, any>,
|
||||
options: QueryObserverOptions<any, any, any, any, any>,
|
||||
): boolean {
|
||||
return (
|
||||
resolveEnabled(options.enabled, query) !== false &&
|
||||
query.isStaleByTime(resolveStaleTime(options.staleTime, query))
|
||||
)
|
||||
}
|
||||
|
||||
// this function would decide if we will update the observer's 'current'
|
||||
// properties after an optimistic reading via getOptimisticResult
|
||||
function shouldAssignObserverCurrentProperties<
|
||||
TQueryFnData = unknown,
|
||||
TError = unknown,
|
||||
TData = TQueryFnData,
|
||||
TQueryData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
observer: QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
|
||||
optimisticResult: QueryObserverResult<TData, TError>,
|
||||
) {
|
||||
// if the newly created result isn't what the observer is holding as current,
|
||||
// then we'll need to update the properties as well
|
||||
if (!shallowEqualObjects(observer.getCurrentResult(), optimisticResult)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// basically, just keep previous properties if nothing changed
|
||||
return false
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { timeoutManager } from './timeoutManager'
|
||||
import { isServer, isValidTimeout } from './utils'
|
||||
import type { ManagedTimerId } from './timeoutManager'
|
||||
|
||||
export abstract class Removable {
|
||||
gcTime!: number
|
||||
#gcTimeout?: ManagedTimerId
|
||||
|
||||
destroy(): void {
|
||||
this.clearGcTimeout()
|
||||
}
|
||||
|
||||
protected scheduleGc(): void {
|
||||
this.clearGcTimeout()
|
||||
|
||||
if (isValidTimeout(this.gcTime)) {
|
||||
this.#gcTimeout = timeoutManager.setTimeout(() => {
|
||||
this.optionalRemove()
|
||||
}, this.gcTime)
|
||||
}
|
||||
}
|
||||
|
||||
protected updateGcTime(newGcTime: number | undefined): void {
|
||||
// Default to 5 minutes (Infinity for server-side) if no gcTime is set
|
||||
this.gcTime = Math.max(
|
||||
this.gcTime || 0,
|
||||
newGcTime ?? (isServer ? Infinity : 5 * 60 * 1000),
|
||||
)
|
||||
}
|
||||
|
||||
protected clearGcTimeout() {
|
||||
if (this.#gcTimeout) {
|
||||
timeoutManager.clearTimeout(this.#gcTimeout)
|
||||
this.#gcTimeout = undefined
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract optionalRemove(): void
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
import { focusManager } from './focusManager'
|
||||
import { onlineManager } from './onlineManager'
|
||||
import { pendingThenable } from './thenable'
|
||||
import { isServer, sleep } from './utils'
|
||||
import type { Thenable } from './thenable'
|
||||
import type { CancelOptions, DefaultError, NetworkMode } from './types'
|
||||
|
||||
// TYPES
|
||||
|
||||
interface RetryerConfig<TData = unknown, TError = DefaultError> {
|
||||
fn: () => TData | Promise<TData>
|
||||
initialPromise?: Promise<TData>
|
||||
onCancel?: (error: TError) => void
|
||||
onFail?: (failureCount: number, error: TError) => void
|
||||
onPause?: () => void
|
||||
onContinue?: () => void
|
||||
retry?: RetryValue<TError>
|
||||
retryDelay?: RetryDelayValue<TError>
|
||||
networkMode: NetworkMode | undefined
|
||||
canRun: () => boolean
|
||||
}
|
||||
|
||||
export interface Retryer<TData = unknown> {
|
||||
promise: Promise<TData>
|
||||
cancel: (cancelOptions?: CancelOptions) => void
|
||||
continue: () => Promise<unknown>
|
||||
cancelRetry: () => void
|
||||
continueRetry: () => void
|
||||
canStart: () => boolean
|
||||
start: () => Promise<TData>
|
||||
status: () => 'pending' | 'resolved' | 'rejected'
|
||||
}
|
||||
|
||||
export type RetryValue<TError> = boolean | number | ShouldRetryFunction<TError>
|
||||
|
||||
type ShouldRetryFunction<TError = DefaultError> = (
|
||||
failureCount: number,
|
||||
error: TError,
|
||||
) => boolean
|
||||
|
||||
export type RetryDelayValue<TError> = number | RetryDelayFunction<TError>
|
||||
|
||||
type RetryDelayFunction<TError = DefaultError> = (
|
||||
failureCount: number,
|
||||
error: TError,
|
||||
) => number
|
||||
|
||||
function defaultRetryDelay(failureCount: number) {
|
||||
return Math.min(1000 * 2 ** failureCount, 30000)
|
||||
}
|
||||
|
||||
export function canFetch(networkMode: NetworkMode | undefined): boolean {
|
||||
return (networkMode ?? 'online') === 'online'
|
||||
? onlineManager.isOnline()
|
||||
: true
|
||||
}
|
||||
|
||||
export class CancelledError extends Error {
|
||||
revert?: boolean
|
||||
silent?: boolean
|
||||
constructor(options?: CancelOptions) {
|
||||
super('CancelledError')
|
||||
this.revert = options?.revert
|
||||
this.silent = options?.silent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use instanceof `CancelledError` instead.
|
||||
*/
|
||||
export function isCancelledError(value: any): value is CancelledError {
|
||||
return value instanceof CancelledError
|
||||
}
|
||||
|
||||
export function createRetryer<TData = unknown, TError = DefaultError>(
|
||||
config: RetryerConfig<TData, TError>,
|
||||
): Retryer<TData> {
|
||||
let isRetryCancelled = false
|
||||
let failureCount = 0
|
||||
let continueFn: ((value?: unknown) => void) | undefined
|
||||
|
||||
const thenable = pendingThenable<TData>()
|
||||
|
||||
const isResolved = () =>
|
||||
(thenable.status as Thenable<TData>['status']) !== 'pending'
|
||||
|
||||
const cancel = (cancelOptions?: CancelOptions): void => {
|
||||
if (!isResolved()) {
|
||||
const error = new CancelledError(cancelOptions) as TError
|
||||
reject(error)
|
||||
|
||||
config.onCancel?.(error)
|
||||
}
|
||||
}
|
||||
const cancelRetry = () => {
|
||||
isRetryCancelled = true
|
||||
}
|
||||
|
||||
const continueRetry = () => {
|
||||
isRetryCancelled = false
|
||||
}
|
||||
|
||||
const canContinue = () =>
|
||||
focusManager.isFocused() &&
|
||||
(config.networkMode === 'always' || onlineManager.isOnline()) &&
|
||||
config.canRun()
|
||||
|
||||
const canStart = () => canFetch(config.networkMode) && config.canRun()
|
||||
|
||||
const resolve = (value: any) => {
|
||||
if (!isResolved()) {
|
||||
continueFn?.()
|
||||
thenable.resolve(value)
|
||||
}
|
||||
}
|
||||
|
||||
const reject = (value: any) => {
|
||||
if (!isResolved()) {
|
||||
continueFn?.()
|
||||
thenable.reject(value)
|
||||
}
|
||||
}
|
||||
|
||||
const pause = () => {
|
||||
return new Promise((continueResolve) => {
|
||||
continueFn = (value) => {
|
||||
if (isResolved() || canContinue()) {
|
||||
continueResolve(value)
|
||||
}
|
||||
}
|
||||
config.onPause?.()
|
||||
}).then(() => {
|
||||
continueFn = undefined
|
||||
if (!isResolved()) {
|
||||
config.onContinue?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Create loop function
|
||||
const run = () => {
|
||||
// Do nothing if already resolved
|
||||
if (isResolved()) {
|
||||
return
|
||||
}
|
||||
|
||||
let promiseOrValue: any
|
||||
|
||||
// we can re-use config.initialPromise on the first call of run()
|
||||
const initialPromise =
|
||||
failureCount === 0 ? config.initialPromise : undefined
|
||||
|
||||
// Execute query
|
||||
try {
|
||||
promiseOrValue = initialPromise ?? config.fn()
|
||||
} catch (error) {
|
||||
promiseOrValue = Promise.reject(error)
|
||||
}
|
||||
|
||||
Promise.resolve(promiseOrValue)
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
// Stop if the fetch is already resolved
|
||||
if (isResolved()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Do we need to retry the request?
|
||||
const retry = config.retry ?? (isServer ? 0 : 3)
|
||||
const retryDelay = config.retryDelay ?? defaultRetryDelay
|
||||
const delay =
|
||||
typeof retryDelay === 'function'
|
||||
? retryDelay(failureCount, error)
|
||||
: retryDelay
|
||||
const shouldRetry =
|
||||
retry === true ||
|
||||
(typeof retry === 'number' && failureCount < retry) ||
|
||||
(typeof retry === 'function' && retry(failureCount, error))
|
||||
|
||||
if (isRetryCancelled || !shouldRetry) {
|
||||
// We are done if the query does not need to be retried
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
failureCount++
|
||||
|
||||
// Notify on fail
|
||||
config.onFail?.(failureCount, error)
|
||||
|
||||
// Delay
|
||||
sleep(delay)
|
||||
// Pause if the document is not visible or when the device is offline
|
||||
.then(() => {
|
||||
return canContinue() ? undefined : pause()
|
||||
})
|
||||
.then(() => {
|
||||
if (isRetryCancelled) {
|
||||
reject(error)
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
promise: thenable,
|
||||
status: () => thenable.status,
|
||||
cancel,
|
||||
continue: () => {
|
||||
continueFn?.()
|
||||
return thenable
|
||||
},
|
||||
cancelRetry,
|
||||
continueRetry,
|
||||
canStart,
|
||||
start: () => {
|
||||
// Start loop
|
||||
if (canStart()) {
|
||||
run()
|
||||
} else {
|
||||
pause().then(run)
|
||||
}
|
||||
return thenable
|
||||
},
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
export class Subscribable<TListener extends Function> {
|
||||
protected listeners = new Set<TListener>()
|
||||
|
||||
constructor() {
|
||||
this.subscribe = this.subscribe.bind(this)
|
||||
}
|
||||
|
||||
subscribe(listener: TListener): () => void {
|
||||
this.listeners.add(listener)
|
||||
|
||||
this.onSubscribe()
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
this.onUnsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
hasListeners(): boolean {
|
||||
return this.listeners.size > 0
|
||||
}
|
||||
|
||||
protected onSubscribe(): void {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
protected onUnsubscribe(): void {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Thenable types which matches React's types for promises
|
||||
*
|
||||
* React seemingly uses `.status`, `.value` and `.reason` properties on a promises to optimistically unwrap data from promises
|
||||
*
|
||||
* @see https://github.com/facebook/react/blob/main/packages/shared/ReactTypes.js#L112-L138
|
||||
* @see https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-debug-tools/src/ReactDebugHooks.js#L224-L227
|
||||
*/
|
||||
|
||||
import { noop } from './utils'
|
||||
|
||||
interface Fulfilled<T> {
|
||||
status: 'fulfilled'
|
||||
value: T
|
||||
}
|
||||
interface Rejected {
|
||||
status: 'rejected'
|
||||
reason: unknown
|
||||
}
|
||||
interface Pending<T> {
|
||||
status: 'pending'
|
||||
|
||||
/**
|
||||
* Resolve the promise with a value.
|
||||
* Will remove the `resolve` and `reject` properties from the promise.
|
||||
*/
|
||||
resolve: (value: T) => void
|
||||
/**
|
||||
* Reject the promise with a reason.
|
||||
* Will remove the `resolve` and `reject` properties from the promise.
|
||||
*/
|
||||
reject: (reason: unknown) => void
|
||||
}
|
||||
|
||||
export type FulfilledThenable<T> = Promise<T> & Fulfilled<T>
|
||||
export type RejectedThenable<T> = Promise<T> & Rejected
|
||||
export type PendingThenable<T> = Promise<T> & Pending<T>
|
||||
|
||||
export type Thenable<T> =
|
||||
| FulfilledThenable<T>
|
||||
| RejectedThenable<T>
|
||||
| PendingThenable<T>
|
||||
|
||||
export function pendingThenable<T>(): PendingThenable<T> {
|
||||
let resolve: Pending<T>['resolve']
|
||||
let reject: Pending<T>['reject']
|
||||
// this could use `Promise.withResolvers()` in the future
|
||||
const thenable = new Promise((_resolve, _reject) => {
|
||||
resolve = _resolve
|
||||
reject = _reject
|
||||
}) as PendingThenable<T>
|
||||
|
||||
thenable.status = 'pending'
|
||||
thenable.catch(() => {
|
||||
// prevent unhandled rejection errors
|
||||
})
|
||||
|
||||
function finalize(data: Fulfilled<T> | Rejected) {
|
||||
Object.assign(thenable, data)
|
||||
|
||||
// clear pending props props to avoid calling them twice
|
||||
delete (thenable as Partial<PendingThenable<T>>).resolve
|
||||
delete (thenable as Partial<PendingThenable<T>>).reject
|
||||
}
|
||||
|
||||
thenable.resolve = (value) => {
|
||||
finalize({
|
||||
status: 'fulfilled',
|
||||
value,
|
||||
})
|
||||
|
||||
resolve(value)
|
||||
}
|
||||
thenable.reject = (reason) => {
|
||||
finalize({
|
||||
status: 'rejected',
|
||||
reason,
|
||||
})
|
||||
|
||||
reject(reason)
|
||||
}
|
||||
|
||||
return thenable
|
||||
}
|
||||
|
||||
/**
|
||||
* This function takes a Promise-like input and detects whether the data
|
||||
* is synchronously available or not.
|
||||
*
|
||||
* It does not inspect .status, .value or .reason properties of the promise,
|
||||
* as those are not always available, and the .status of React's promises
|
||||
* should not be considered part of the public API.
|
||||
*/
|
||||
export function tryResolveSync(promise: Promise<unknown> | Thenable<unknown>) {
|
||||
let data: unknown
|
||||
|
||||
promise
|
||||
.then((result) => {
|
||||
data = result
|
||||
return result
|
||||
}, noop)
|
||||
// .catch can be unavailable on certain kinds of thenable's
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
?.catch(noop)
|
||||
|
||||
if (data !== undefined) {
|
||||
return { data }
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* {@link TimeoutManager} does not support passing arguments to the callback.
|
||||
*
|
||||
* `(_: void)` is the argument type inferred by TypeScript's default typings for
|
||||
* `setTimeout(cb, number)`.
|
||||
* If we don't accept a single void argument, then
|
||||
* `new Promise(resolve => timeoutManager.setTimeout(resolve, N))` is a type error.
|
||||
*/
|
||||
export type TimeoutCallback = (_: void) => void
|
||||
|
||||
/**
|
||||
* Wrapping `setTimeout` is awkward from a typing perspective because platform
|
||||
* typings may extend the return type of `setTimeout`. For example, NodeJS
|
||||
* typings add `NodeJS.Timeout`; but a non-default `timeoutManager` may not be
|
||||
* able to return such a type.
|
||||
*/
|
||||
export type ManagedTimerId = number | { [Symbol.toPrimitive]: () => number }
|
||||
|
||||
/**
|
||||
* Backend for timer functions.
|
||||
*/
|
||||
export type TimeoutProvider<TTimerId extends ManagedTimerId = ManagedTimerId> =
|
||||
{
|
||||
readonly setTimeout: (callback: TimeoutCallback, delay: number) => TTimerId
|
||||
readonly clearTimeout: (timeoutId: TTimerId | undefined) => void
|
||||
|
||||
readonly setInterval: (callback: TimeoutCallback, delay: number) => TTimerId
|
||||
readonly clearInterval: (intervalId: TTimerId | undefined) => void
|
||||
}
|
||||
|
||||
export const defaultTimeoutProvider: TimeoutProvider<
|
||||
ReturnType<typeof setTimeout>
|
||||
> = {
|
||||
// We need the wrapper function syntax below instead of direct references to
|
||||
// global setTimeout etc.
|
||||
//
|
||||
// BAD: `setTimeout: setTimeout`
|
||||
// GOOD: `setTimeout: (cb, delay) => setTimeout(cb, delay)`
|
||||
//
|
||||
// If we use direct references here, then anything that wants to spy on or
|
||||
// replace the global setTimeout (like tests) won't work since we'll already
|
||||
// have a hard reference to the original implementation at the time when this
|
||||
// file was imported.
|
||||
setTimeout: (callback, delay) => setTimeout(callback, delay),
|
||||
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
|
||||
|
||||
setInterval: (callback, delay) => setInterval(callback, delay),
|
||||
clearInterval: (intervalId) => clearInterval(intervalId),
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows customization of how timeouts are created.
|
||||
*
|
||||
* @tanstack/query-core makes liberal use of timeouts to implement `staleTime`
|
||||
* and `gcTime`. The default TimeoutManager provider uses the platform's global
|
||||
* `setTimeout` implementation, which is known to have scalability issues with
|
||||
* thousands of timeouts on the event loop.
|
||||
*
|
||||
* If you hit this limitation, consider providing a custom TimeoutProvider that
|
||||
* coalesces timeouts.
|
||||
*/
|
||||
export class TimeoutManager implements Omit<TimeoutProvider, 'name'> {
|
||||
// We cannot have TimeoutManager<T> as we must instantiate it with a concrete
|
||||
// type at app boot; and if we leave that type, then any new timer provider
|
||||
// would need to support ReturnType<typeof setTimeout>, which is infeasible.
|
||||
//
|
||||
// We settle for type safety for the TimeoutProvider type, and accept that
|
||||
// this class is unsafe internally to allow for extension.
|
||||
#provider: TimeoutProvider<any> = defaultTimeoutProvider
|
||||
#providerCalled = false
|
||||
|
||||
setTimeoutProvider<TTimerId extends ManagedTimerId>(
|
||||
provider: TimeoutProvider<TTimerId>,
|
||||
): void {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (this.#providerCalled && provider !== this.#provider) {
|
||||
// After changing providers, `clearTimeout` will not work as expected for
|
||||
// timeouts from the previous provider.
|
||||
//
|
||||
// Since they may allocate the same timeout ID, clearTimeout may cancel an
|
||||
// arbitrary different timeout, or unexpected no-op.
|
||||
//
|
||||
// We could protect against this by mixing the timeout ID bits
|
||||
// deterministically with some per-provider bits.
|
||||
//
|
||||
// We could internally queue `setTimeout` calls to `TimeoutManager` until
|
||||
// some API call to set the initial provider.
|
||||
console.error(
|
||||
`[timeoutManager]: Switching provider after calls to previous provider might result in unexpected behavior.`,
|
||||
{ previous: this.#provider, provider },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.#provider = provider
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.#providerCalled = false
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(callback: TimeoutCallback, delay: number): ManagedTimerId {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.#providerCalled = true
|
||||
}
|
||||
return this.#provider.setTimeout(callback, delay)
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId: ManagedTimerId | undefined): void {
|
||||
this.#provider.clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
setInterval(callback: TimeoutCallback, delay: number): ManagedTimerId {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
this.#providerCalled = true
|
||||
}
|
||||
return this.#provider.setInterval(callback, delay)
|
||||
}
|
||||
|
||||
clearInterval(intervalId: ManagedTimerId | undefined): void {
|
||||
this.#provider.clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
|
||||
export const timeoutManager = new TimeoutManager()
|
||||
|
||||
/**
|
||||
* In many cases code wants to delay to the next event loop tick; this is not
|
||||
* mediated by {@link timeoutManager}.
|
||||
*
|
||||
* This function is provided to make auditing the `tanstack/query-core` for
|
||||
* incorrect use of system `setTimeout` easier.
|
||||
*/
|
||||
export function systemSetTimeoutZero(callback: TimeoutCallback): void {
|
||||
setTimeout(callback, 0)
|
||||
}
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
import { timeoutManager } from './timeoutManager'
|
||||
import type {
|
||||
DefaultError,
|
||||
Enabled,
|
||||
FetchStatus,
|
||||
MutationKey,
|
||||
MutationStatus,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
QueryOptions,
|
||||
StaleTime,
|
||||
StaleTimeFunction,
|
||||
} from './types'
|
||||
import type { Mutation } from './mutation'
|
||||
import type { FetchOptions, Query } from './query'
|
||||
|
||||
// TYPES
|
||||
|
||||
type DropLast<T extends ReadonlyArray<unknown>> = T extends readonly [
|
||||
...infer R,
|
||||
unknown,
|
||||
]
|
||||
? readonly [...R]
|
||||
: never
|
||||
|
||||
type TuplePrefixes<T extends ReadonlyArray<unknown>> = T extends readonly []
|
||||
? readonly []
|
||||
: TuplePrefixes<DropLast<T>> | T
|
||||
|
||||
export interface QueryFilters<TQueryKey extends QueryKey = QueryKey> {
|
||||
/**
|
||||
* Filter to active queries, inactive queries or all queries
|
||||
*/
|
||||
type?: QueryTypeFilter
|
||||
/**
|
||||
* Match query key exactly
|
||||
*/
|
||||
exact?: boolean
|
||||
/**
|
||||
* Include queries matching this predicate function
|
||||
*/
|
||||
predicate?: (query: Query) => boolean
|
||||
/**
|
||||
* Include queries matching this query key
|
||||
*/
|
||||
queryKey?: TQueryKey | TuplePrefixes<TQueryKey>
|
||||
/**
|
||||
* Include or exclude stale queries
|
||||
*/
|
||||
stale?: boolean
|
||||
/**
|
||||
* Include queries matching their fetchStatus
|
||||
*/
|
||||
fetchStatus?: FetchStatus
|
||||
}
|
||||
|
||||
export interface MutationFilters<
|
||||
TData = unknown,
|
||||
TError = DefaultError,
|
||||
TVariables = unknown,
|
||||
TOnMutateResult = unknown,
|
||||
> {
|
||||
/**
|
||||
* Match mutation key exactly
|
||||
*/
|
||||
exact?: boolean
|
||||
/**
|
||||
* Include mutations matching this predicate function
|
||||
*/
|
||||
predicate?: (
|
||||
mutation: Mutation<TData, TError, TVariables, TOnMutateResult>,
|
||||
) => boolean
|
||||
/**
|
||||
* Include mutations matching this mutation key
|
||||
*/
|
||||
mutationKey?: TuplePrefixes<MutationKey>
|
||||
/**
|
||||
* Filter by mutation status
|
||||
*/
|
||||
status?: MutationStatus
|
||||
}
|
||||
|
||||
export type Updater<TInput, TOutput> = TOutput | ((input: TInput) => TOutput)
|
||||
|
||||
export type QueryTypeFilter = 'all' | 'active' | 'inactive'
|
||||
|
||||
// UTILS
|
||||
|
||||
export const isServer = typeof window === 'undefined' || 'Deno' in globalThis
|
||||
|
||||
export function noop(): void
|
||||
export function noop(): undefined
|
||||
export function noop() {}
|
||||
|
||||
export function functionalUpdate<TInput, TOutput>(
|
||||
updater: Updater<TInput, TOutput>,
|
||||
input: TInput,
|
||||
): TOutput {
|
||||
return typeof updater === 'function'
|
||||
? (updater as (_: TInput) => TOutput)(input)
|
||||
: updater
|
||||
}
|
||||
|
||||
export function isValidTimeout(value: unknown): value is number {
|
||||
return typeof value === 'number' && value >= 0 && value !== Infinity
|
||||
}
|
||||
|
||||
export function timeUntilStale(updatedAt: number, staleTime?: number): number {
|
||||
return Math.max(updatedAt + (staleTime || 0) - Date.now(), 0)
|
||||
}
|
||||
|
||||
export function resolveStaleTime<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
staleTime:
|
||||
| undefined
|
||||
| StaleTimeFunction<TQueryFnData, TError, TData, TQueryKey>,
|
||||
query: Query<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): StaleTime | undefined {
|
||||
return typeof staleTime === 'function' ? staleTime(query) : staleTime
|
||||
}
|
||||
|
||||
export function resolveEnabled<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
enabled: undefined | Enabled<TQueryFnData, TError, TData, TQueryKey>,
|
||||
query: Query<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): boolean | undefined {
|
||||
return typeof enabled === 'function' ? enabled(query) : enabled
|
||||
}
|
||||
|
||||
export function matchQuery(
|
||||
filters: QueryFilters,
|
||||
query: Query<any, any, any, any>,
|
||||
): boolean {
|
||||
const {
|
||||
type = 'all',
|
||||
exact,
|
||||
fetchStatus,
|
||||
predicate,
|
||||
queryKey,
|
||||
stale,
|
||||
} = filters
|
||||
|
||||
if (queryKey) {
|
||||
if (exact) {
|
||||
if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) {
|
||||
return false
|
||||
}
|
||||
} else if (!partialMatchKey(query.queryKey, queryKey)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (type !== 'all') {
|
||||
const isActive = query.isActive()
|
||||
if (type === 'active' && !isActive) {
|
||||
return false
|
||||
}
|
||||
if (type === 'inactive' && isActive) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof stale === 'boolean' && query.isStale() !== stale) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (fetchStatus && fetchStatus !== query.state.fetchStatus) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (predicate && !predicate(query)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchMutation(
|
||||
filters: MutationFilters,
|
||||
mutation: Mutation<any, any>,
|
||||
): boolean {
|
||||
const { exact, status, predicate, mutationKey } = filters
|
||||
if (mutationKey) {
|
||||
if (!mutation.options.mutationKey) {
|
||||
return false
|
||||
}
|
||||
if (exact) {
|
||||
if (hashKey(mutation.options.mutationKey) !== hashKey(mutationKey)) {
|
||||
return false
|
||||
}
|
||||
} else if (!partialMatchKey(mutation.options.mutationKey, mutationKey)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (status && mutation.state.status !== status) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (predicate && !predicate(mutation)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function hashQueryKeyByOptions<TQueryKey extends QueryKey = QueryKey>(
|
||||
queryKey: TQueryKey,
|
||||
options?: Pick<QueryOptions<any, any, any, any>, 'queryKeyHashFn'>,
|
||||
): string {
|
||||
const hashFn = options?.queryKeyHashFn || hashKey
|
||||
return hashFn(queryKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default query & mutation keys hash function.
|
||||
* Hashes the value into a stable hash.
|
||||
*/
|
||||
export function hashKey(queryKey: QueryKey | MutationKey): string {
|
||||
return JSON.stringify(queryKey, (_, val) =>
|
||||
isPlainObject(val)
|
||||
? Object.keys(val)
|
||||
.sort()
|
||||
.reduce((result, key) => {
|
||||
result[key] = val[key]
|
||||
return result
|
||||
}, {} as any)
|
||||
: val,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if key `b` partially matches with key `a`.
|
||||
*/
|
||||
export function partialMatchKey(a: QueryKey, b: QueryKey): boolean
|
||||
export function partialMatchKey(a: any, b: any): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof a !== typeof b) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
||||
return Object.keys(b).every((key) => partialMatchKey(a[key], b[key]))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const hasOwn = Object.prototype.hasOwnProperty
|
||||
|
||||
/**
|
||||
* This function returns `a` if `b` is deeply equal.
|
||||
* If not, it will replace any deeply equal children of `b` with those of `a`.
|
||||
* This can be used for structural sharing between JSON values for example.
|
||||
*/
|
||||
export function replaceEqualDeep<T>(a: unknown, b: T, depth?: number): T
|
||||
export function replaceEqualDeep(a: any, b: any, depth = 0): any {
|
||||
if (a === b) {
|
||||
return a
|
||||
}
|
||||
|
||||
if (depth > 500) return b
|
||||
|
||||
const array = isPlainArray(a) && isPlainArray(b)
|
||||
|
||||
if (!array && !(isPlainObject(a) && isPlainObject(b))) return b
|
||||
|
||||
const aItems = array ? a : Object.keys(a)
|
||||
const aSize = aItems.length
|
||||
const bItems = array ? b : Object.keys(b)
|
||||
const bSize = bItems.length
|
||||
const copy: any = array ? new Array(bSize) : {}
|
||||
|
||||
let equalItems = 0
|
||||
|
||||
for (let i = 0; i < bSize; i++) {
|
||||
const key: any = array ? i : bItems[i]
|
||||
const aItem = a[key]
|
||||
const bItem = b[key]
|
||||
|
||||
if (aItem === bItem) {
|
||||
copy[key] = aItem
|
||||
if (array ? i < aSize : hasOwn.call(a, key)) equalItems++
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
aItem === null ||
|
||||
bItem === null ||
|
||||
typeof aItem !== 'object' ||
|
||||
typeof bItem !== 'object'
|
||||
) {
|
||||
copy[key] = bItem
|
||||
continue
|
||||
}
|
||||
|
||||
const v = replaceEqualDeep(aItem, bItem, depth + 1)
|
||||
copy[key] = v
|
||||
if (v === aItem) equalItems++
|
||||
}
|
||||
|
||||
return aSize === bSize && equalItems === aSize ? a : copy
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow compare objects.
|
||||
*/
|
||||
export function shallowEqualObjects<T extends Record<string, any>>(
|
||||
a: T,
|
||||
b: T | undefined,
|
||||
): boolean {
|
||||
if (!b || Object.keys(a).length !== Object.keys(b).length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key in a) {
|
||||
if (a[key] !== b[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isPlainArray(value: unknown): value is Array<unknown> {
|
||||
return Array.isArray(value) && value.length === Object.keys(value).length
|
||||
}
|
||||
|
||||
// Copied from: https://github.com/jonschlinkert/is-plain-object
|
||||
export function isPlainObject(o: any): o is Record<PropertyKey, unknown> {
|
||||
if (!hasObjectPrototype(o)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If has no constructor
|
||||
const ctor = o.constructor
|
||||
if (ctor === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If has modified prototype
|
||||
const prot = ctor.prototype
|
||||
if (!hasObjectPrototype(prot)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If constructor does not have an Object-specific method
|
||||
if (!prot.hasOwnProperty('isPrototypeOf')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Handles Objects created by Object.create(<arbitrary prototype>)
|
||||
if (Object.getPrototypeOf(o) !== Object.prototype) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Most likely a plain Object
|
||||
return true
|
||||
}
|
||||
|
||||
function hasObjectPrototype(o: any): boolean {
|
||||
return Object.prototype.toString.call(o) === '[object Object]'
|
||||
}
|
||||
|
||||
export function sleep(timeout: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
timeoutManager.setTimeout(resolve, timeout)
|
||||
})
|
||||
}
|
||||
|
||||
export function replaceData<
|
||||
TData,
|
||||
TOptions extends QueryOptions<any, any, any, any>,
|
||||
>(prevData: TData | undefined, data: TData, options: TOptions): TData {
|
||||
if (typeof options.structuralSharing === 'function') {
|
||||
return options.structuralSharing(prevData, data) as TData
|
||||
} else if (options.structuralSharing !== false) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
try {
|
||||
return replaceEqualDeep(prevData, data)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Structural sharing requires data to be JSON serializable. To fix this, turn off structuralSharing or return JSON-serializable data from your queryFn. [${options.queryHash}]: ${error}`,
|
||||
)
|
||||
|
||||
// Prevent the replaceEqualDeep from being called again down below.
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// Structurally share data between prev and new data if needed
|
||||
return replaceEqualDeep(prevData, data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function keepPreviousData<T>(
|
||||
previousData: T | undefined,
|
||||
): T | undefined {
|
||||
return previousData
|
||||
}
|
||||
|
||||
export function addToEnd<T>(items: Array<T>, item: T, max = 0): Array<T> {
|
||||
const newItems = [...items, item]
|
||||
return max && newItems.length > max ? newItems.slice(1) : newItems
|
||||
}
|
||||
|
||||
export function addToStart<T>(items: Array<T>, item: T, max = 0): Array<T> {
|
||||
const newItems = [item, ...items]
|
||||
return max && newItems.length > max ? newItems.slice(0, -1) : newItems
|
||||
}
|
||||
|
||||
export const skipToken = Symbol()
|
||||
export type SkipToken = typeof skipToken
|
||||
|
||||
export function ensureQueryFn<
|
||||
TQueryFnData = unknown,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: {
|
||||
queryFn?: QueryFunction<TQueryFnData, TQueryKey> | SkipToken
|
||||
queryHash?: string
|
||||
},
|
||||
fetchOptions?: FetchOptions<TQueryFnData>,
|
||||
): QueryFunction<TQueryFnData, TQueryKey> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (options.queryFn === skipToken) {
|
||||
console.error(
|
||||
`Attempted to invoke queryFn when set to skipToken. This is likely a configuration error. Query hash: '${options.queryHash}'`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// if we attempt to retry a fetch that was triggered from an initialPromise
|
||||
// when we don't have a queryFn yet, we can't retry, so we just return the already rejected initialPromise
|
||||
// if an observer has already mounted, we will be able to retry with that queryFn
|
||||
if (!options.queryFn && fetchOptions?.initialPromise) {
|
||||
return () => fetchOptions.initialPromise!
|
||||
}
|
||||
|
||||
if (!options.queryFn || options.queryFn === skipToken) {
|
||||
return () =>
|
||||
Promise.reject(new Error(`Missing queryFn: '${options.queryHash}'`))
|
||||
}
|
||||
|
||||
return options.queryFn
|
||||
}
|
||||
|
||||
export function shouldThrowError<T extends (...args: Array<any>) => boolean>(
|
||||
throwOnError: boolean | T | undefined,
|
||||
params: Parameters<T>,
|
||||
): boolean {
|
||||
// Allow throwOnError function to override throwing behavior on a per-error basis
|
||||
if (typeof throwOnError === 'function') {
|
||||
return throwOnError(...params)
|
||||
}
|
||||
|
||||
return !!throwOnError
|
||||
}
|
||||
|
||||
export function addConsumeAwareSignal<T>(
|
||||
object: T,
|
||||
getSignal: () => AbortSignal,
|
||||
onCancelled: VoidFunction,
|
||||
): T & { signal: AbortSignal } {
|
||||
let consumed = false
|
||||
let signal: AbortSignal | undefined
|
||||
|
||||
Object.defineProperty(object, 'signal', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
signal ??= getSignal()
|
||||
if (consumed) {
|
||||
return signal
|
||||
}
|
||||
|
||||
consumed = true
|
||||
if (signal.aborted) {
|
||||
onCancelled()
|
||||
} else {
|
||||
signal.addEventListener('abort', onCancelled, { once: true })
|
||||
}
|
||||
|
||||
return signal
|
||||
},
|
||||
})
|
||||
|
||||
return object as T & { signal: AbortSignal }
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
const IsRestoringContext = React.createContext(false)
|
||||
|
||||
export const useIsRestoring = () => React.useContext(IsRestoringContext)
|
||||
export const IsRestoringProvider = IsRestoringContext.Provider
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
import type { QueryClient } from '@tanstack/query-core'
|
||||
|
||||
export const QueryClientContext = React.createContext<QueryClient | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
export const useQueryClient = (queryClient?: QueryClient) => {
|
||||
const client = React.useContext(QueryClientContext)
|
||||
|
||||
if (queryClient) {
|
||||
return queryClient
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
throw new Error('No QueryClient set, use QueryClientProvider to set one')
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
export type QueryClientProviderProps = {
|
||||
client: QueryClient
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const QueryClientProvider = ({
|
||||
client,
|
||||
children,
|
||||
}: QueryClientProviderProps): React.JSX.Element => {
|
||||
React.useEffect(() => {
|
||||
client.mount()
|
||||
return () => {
|
||||
client.unmount()
|
||||
}
|
||||
}, [client])
|
||||
|
||||
return (
|
||||
<QueryClientContext.Provider value={client}>
|
||||
{children}
|
||||
</QueryClientContext.Provider>
|
||||
)
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
// CONTEXT
|
||||
export type QueryErrorResetFunction = () => void
|
||||
export type QueryErrorIsResetFunction = () => boolean
|
||||
export type QueryErrorClearResetFunction = () => void
|
||||
|
||||
export interface QueryErrorResetBoundaryValue {
|
||||
clearReset: QueryErrorClearResetFunction
|
||||
isReset: QueryErrorIsResetFunction
|
||||
reset: QueryErrorResetFunction
|
||||
}
|
||||
|
||||
function createValue(): QueryErrorResetBoundaryValue {
|
||||
let isReset = false
|
||||
return {
|
||||
clearReset: () => {
|
||||
isReset = false
|
||||
},
|
||||
reset: () => {
|
||||
isReset = true
|
||||
},
|
||||
isReset: () => {
|
||||
return isReset
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const QueryErrorResetBoundaryContext = React.createContext(createValue())
|
||||
|
||||
// HOOK
|
||||
|
||||
export const useQueryErrorResetBoundary = () =>
|
||||
React.useContext(QueryErrorResetBoundaryContext)
|
||||
|
||||
// COMPONENT
|
||||
|
||||
export type QueryErrorResetBoundaryFunction = (
|
||||
value: QueryErrorResetBoundaryValue,
|
||||
) => React.ReactNode
|
||||
|
||||
export interface QueryErrorResetBoundaryProps {
|
||||
children: QueryErrorResetBoundaryFunction | React.ReactNode
|
||||
}
|
||||
|
||||
export const QueryErrorResetBoundary = ({
|
||||
children,
|
||||
}: QueryErrorResetBoundaryProps) => {
|
||||
const [value] = React.useState(() => createValue())
|
||||
return (
|
||||
<QueryErrorResetBoundaryContext.Provider value={value}>
|
||||
{typeof children === 'function' ? children(value) : children}
|
||||
</QueryErrorResetBoundaryContext.Provider>
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
import { shouldThrowError } from '@tanstack/query-core'
|
||||
import type {
|
||||
DefaultedQueryObserverOptions,
|
||||
Query,
|
||||
QueryKey,
|
||||
QueryObserverResult,
|
||||
ThrowOnError,
|
||||
} from '@tanstack/query-core'
|
||||
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
|
||||
|
||||
export const ensurePreventErrorBoundaryRetry = <
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
options: DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
query: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined,
|
||||
) => {
|
||||
const throwOnError =
|
||||
query?.state.error && typeof options.throwOnError === 'function'
|
||||
? shouldThrowError(options.throwOnError, [query.state.error, query])
|
||||
: options.throwOnError
|
||||
|
||||
if (
|
||||
options.suspense ||
|
||||
options.experimental_prefetchInRender ||
|
||||
throwOnError
|
||||
) {
|
||||
// Prevent retrying failed query if the error boundary has not been reset yet
|
||||
if (!errorResetBoundary.isReset()) {
|
||||
options.retryOnMount = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useClearResetErrorBoundary = (
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
) => {
|
||||
React.useEffect(() => {
|
||||
errorResetBoundary.clearReset()
|
||||
}, [errorResetBoundary])
|
||||
}
|
||||
|
||||
export const getHasError = <
|
||||
TData,
|
||||
TError,
|
||||
TQueryFnData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>({
|
||||
result,
|
||||
errorResetBoundary,
|
||||
throwOnError,
|
||||
query,
|
||||
suspense,
|
||||
}: {
|
||||
result: QueryObserverResult<TData, TError>
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue
|
||||
throwOnError: ThrowOnError<TQueryFnData, TError, TQueryData, TQueryKey>
|
||||
query: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined
|
||||
suspense: boolean | undefined
|
||||
}) => {
|
||||
return (
|
||||
result.isError &&
|
||||
!errorResetBoundary.isReset() &&
|
||||
!result.isFetching &&
|
||||
query &&
|
||||
((suspense && result.data === undefined) ||
|
||||
shouldThrowError(throwOnError, [result.error, query]))
|
||||
)
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
DefaultError,
|
||||
DefaultedQueryObserverOptions,
|
||||
Query,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
QueryObserverResult,
|
||||
} from '@tanstack/query-core'
|
||||
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
|
||||
|
||||
export const defaultThrowOnError = <
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
_error: TError,
|
||||
query: Query<TQueryFnData, TError, TData, TQueryKey>,
|
||||
) => query.state.data === undefined
|
||||
|
||||
export const ensureSuspenseTimers = (
|
||||
defaultedOptions: DefaultedQueryObserverOptions<any, any, any, any, any>,
|
||||
) => {
|
||||
if (defaultedOptions.suspense) {
|
||||
// Handle staleTime to ensure minimum 1000ms in Suspense mode
|
||||
// This prevents unnecessary refetching when components remount after suspending
|
||||
const MIN_SUSPENSE_TIME_MS = 1000
|
||||
|
||||
const clamp = (value: number | 'static' | undefined) =>
|
||||
value === 'static'
|
||||
? value
|
||||
: Math.max(value ?? MIN_SUSPENSE_TIME_MS, MIN_SUSPENSE_TIME_MS)
|
||||
|
||||
const originalStaleTime = defaultedOptions.staleTime
|
||||
defaultedOptions.staleTime =
|
||||
typeof originalStaleTime === 'function'
|
||||
? (...args) => clamp(originalStaleTime(...args))
|
||||
: clamp(originalStaleTime)
|
||||
|
||||
if (typeof defaultedOptions.gcTime === 'number') {
|
||||
defaultedOptions.gcTime = Math.max(
|
||||
defaultedOptions.gcTime,
|
||||
MIN_SUSPENSE_TIME_MS,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const willFetch = (
|
||||
result: QueryObserverResult<any, any>,
|
||||
isRestoring: boolean,
|
||||
) => result.isLoading && result.isFetching && !isRestoring
|
||||
|
||||
export const shouldSuspend = (
|
||||
defaultedOptions:
|
||||
| DefaultedQueryObserverOptions<any, any, any, any, any>
|
||||
| undefined,
|
||||
result: QueryObserverResult<any, any>,
|
||||
) => defaultedOptions?.suspense && result.isPending
|
||||
|
||||
export const fetchOptimistic = <
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
defaultedOptions: DefaultedQueryObserverOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
observer: QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
|
||||
errorResetBoundary: QueryErrorResetBoundaryValue,
|
||||
) =>
|
||||
observer.fetchOptimistic(defaultedOptions).catch(() => {
|
||||
errorResetBoundary.clearReset()
|
||||
})
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
|
||||
import { isServer, noop, notifyManager } from '@tanstack/query-core'
|
||||
import { useQueryClient } from './QueryClientProvider'
|
||||
import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
|
||||
import {
|
||||
ensurePreventErrorBoundaryRetry,
|
||||
getHasError,
|
||||
useClearResetErrorBoundary,
|
||||
} from './errorBoundaryUtils'
|
||||
import { useIsRestoring } from './IsRestoringProvider'
|
||||
import {
|
||||
ensureSuspenseTimers,
|
||||
fetchOptimistic,
|
||||
shouldSuspend,
|
||||
willFetch,
|
||||
} from './suspense'
|
||||
import type {
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
QueryObserver,
|
||||
QueryObserverResult,
|
||||
} from '@tanstack/query-core'
|
||||
import type { UseBaseQueryOptions } from './types'
|
||||
|
||||
export function useBaseQuery<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey extends QueryKey,
|
||||
>(
|
||||
options: UseBaseQueryOptions<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TData,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>,
|
||||
Observer: typeof QueryObserver,
|
||||
queryClient?: QueryClient,
|
||||
): QueryObserverResult<TData, TError> {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new Error(
|
||||
'Bad argument type. Starting with v5, only the "Object" form is allowed when calling query related functions. Please use the error stack to find the culprit call. More info here: https://tanstack.com/query/latest/docs/react/guides/migrating-to-v5#supports-a-single-signature-one-object',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isRestoring = useIsRestoring()
|
||||
const errorResetBoundary = useQueryErrorResetBoundary()
|
||||
const client = useQueryClient(queryClient)
|
||||
const defaultedOptions = client.defaultQueryOptions(options)
|
||||
;(client.getDefaultOptions().queries as any)?._experimental_beforeQuery?.(
|
||||
defaultedOptions,
|
||||
)
|
||||
|
||||
const query = client
|
||||
.getQueryCache()
|
||||
.get<
|
||||
TQueryFnData,
|
||||
TError,
|
||||
TQueryData,
|
||||
TQueryKey
|
||||
>(defaultedOptions.queryHash)
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!defaultedOptions.queryFn) {
|
||||
console.error(
|
||||
`[${defaultedOptions.queryHash}]: No queryFn was passed as an option, and no default queryFn was found. The queryFn parameter is only optional when using a default queryFn. More info here: https://tanstack.com/query/latest/docs/framework/react/guides/default-query-function`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure results are optimistically set in fetching state before subscribing or updating options
|
||||
defaultedOptions._optimisticResults = isRestoring
|
||||
? 'isRestoring'
|
||||
: 'optimistic'
|
||||
|
||||
ensureSuspenseTimers(defaultedOptions)
|
||||
ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary, query)
|
||||
useClearResetErrorBoundary(errorResetBoundary)
|
||||
|
||||
// this needs to be invoked before creating the Observer because that can create a cache entry
|
||||
const isNewCacheEntry = !client
|
||||
.getQueryCache()
|
||||
.get(defaultedOptions.queryHash)
|
||||
|
||||
const [observer] = React.useState(
|
||||
() =>
|
||||
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
|
||||
client,
|
||||
defaultedOptions,
|
||||
),
|
||||
)
|
||||
|
||||
// note: this must be called before useSyncExternalStore
|
||||
const result = observer.getOptimisticResult(defaultedOptions)
|
||||
|
||||
const shouldSubscribe = !isRestoring && options.subscribed !== false
|
||||
React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(onStoreChange) => {
|
||||
const unsubscribe = shouldSubscribe
|
||||
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
|
||||
: noop
|
||||
|
||||
// Update result to make sure we did not miss any query updates
|
||||
// between creating the observer and subscribing to it.
|
||||
observer.updateResult()
|
||||
|
||||
return unsubscribe
|
||||
},
|
||||
[observer, shouldSubscribe],
|
||||
),
|
||||
() => observer.getCurrentResult(),
|
||||
() => observer.getCurrentResult(),
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
observer.setOptions(defaultedOptions)
|
||||
}, [defaultedOptions, observer])
|
||||
|
||||
// Handle suspense
|
||||
if (shouldSuspend(defaultedOptions, result)) {
|
||||
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
|
||||
}
|
||||
|
||||
// Handle error boundary
|
||||
if (
|
||||
getHasError({
|
||||
result,
|
||||
errorResetBoundary,
|
||||
throwOnError: defaultedOptions.throwOnError,
|
||||
query,
|
||||
suspense: defaultedOptions.suspense,
|
||||
})
|
||||
) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
;(client.getDefaultOptions().queries as any)?._experimental_afterQuery?.(
|
||||
defaultedOptions,
|
||||
result,
|
||||
)
|
||||
|
||||
if (
|
||||
defaultedOptions.experimental_prefetchInRender &&
|
||||
!isServer &&
|
||||
willFetch(result, isRestoring)
|
||||
) {
|
||||
const promise = isNewCacheEntry
|
||||
? // Fetch immediately on render in order to ensure `.promise` is resolved even if the component is unmounted
|
||||
fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
|
||||
: // subscribe to the "cache promise" so that we can finalize the currentThenable once data comes in
|
||||
query?.promise
|
||||
|
||||
promise?.catch(noop).finally(() => {
|
||||
// `.updateResult()` will trigger `.#currentThenable` to finalize
|
||||
observer.updateResult()
|
||||
})
|
||||
}
|
||||
|
||||
// Handle result property usage tracking
|
||||
return !defaultedOptions.notifyOnChangeProps
|
||||
? observer.trackResult(result)
|
||||
: result
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
import { QueryObserver } from '@tanstack/query-core'
|
||||
import { useBaseQuery } from './useBaseQuery'
|
||||
import type {
|
||||
DefaultError,
|
||||
NoInfer,
|
||||
QueryClient,
|
||||
QueryKey,
|
||||
} from '@tanstack/query-core'
|
||||
import type {
|
||||
DefinedUseQueryResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from './types'
|
||||
import type {
|
||||
DefinedInitialDataOptions,
|
||||
UndefinedInitialDataOptions,
|
||||
} from './queryOptions'
|
||||
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): DefinedUseQueryResult<NoInfer<TData>, TError>
|
||||
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UndefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<NoInfer<TData>, TError>
|
||||
|
||||
export function useQuery<
|
||||
TQueryFnData = unknown,
|
||||
TError = DefaultError,
|
||||
TData = TQueryFnData,
|
||||
TQueryKey extends QueryKey = QueryKey,
|
||||
>(
|
||||
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||
queryClient?: QueryClient,
|
||||
): UseQueryResult<NoInfer<TData>, TError>
|
||||
|
||||
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
|
||||
return useBaseQuery(options, QueryObserver, queryClient)
|
||||
}
|
||||
Reference in New Issue
Block a user