Created
May 8, 2025 11:49
-
-
Save sibbng/a085a48ce1844cb48d1c0711d5cba516 to your computer and use it in GitHub Desktop.
x
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { type Context, createContext } from 'voby'; | |
| import type { QueryClient } from './useQuery.ts'; | |
| // #region Context | |
| export const QueryClientContext: Context<QueryClient> = | |
| createContext<QueryClient>(); | |
| export const QueryClientProvider = | |
| QueryClientContext.Provider as typeof QueryClientContext.Provider; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "imports": { | |
| "voby": "https://esm.sh/voby", | |
| "animejs": "https://esm.sh/animejs" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export * from './context.ts'; | |
| export * from './useQuery.ts'; | |
| export * from './useMutation.ts'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @theme { | |
| --font-sans: 'Inter', sans-serif; | |
| } | |
| @custom-variant dark (&:where(.dark, .dark *)); | |
| @layer { | |
| body { | |
| @apply bg-neutral-400; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| $, | |
| type FunctionMaybe, | |
| type Observable, | |
| type ObservableMaybe, | |
| type ObservableReadonly, | |
| useEffect, | |
| useMemo, | |
| useRoot, | |
| useTimeout, | |
| } from 'voby'; | |
| import { type QueryClient, type QueryKey, useQueryClient } from './useQuery.ts'; | |
| import { hashFn } from './utils.ts'; | |
| type MutationStatus = 'idle' | 'pending' | 'success' | 'error'; | |
| export type MutationState< | |
| TData = unknown, | |
| TError = unknown, | |
| TVariables = unknown, | |
| TContext = unknown, | |
| > = { | |
| data: Observable<TData | undefined>; | |
| error: Observable<TError | null>; | |
| status: Observable<MutationStatus>; | |
| failureCount: Observable<number>; | |
| failureReason: Observable<TError | null>; | |
| isPaused: Observable<boolean>; | |
| submittedAt: Observable<number | undefined>; | |
| variables: Observable<TVariables | undefined>; | |
| isError: Observable<boolean>; | |
| isIdle: Observable<boolean>; | |
| isPending: Observable<boolean>; | |
| isSuccess: Observable<boolean>; | |
| meta: Observable<Record<string, unknown>>; | |
| }; | |
| export type MutationObject< | |
| TData = unknown, | |
| TError = unknown, | |
| TVariables = unknown, | |
| TContext = unknown, | |
| > = { | |
| state: MutationState<TData, TError, TVariables, TContext>; | |
| resolvedOptions: MutationOptions<TData, TError, TVariables, TContext>; | |
| mutate: ( | |
| variables: TVariables, | |
| options?: MutateOptions<TData, TError, TVariables, TContext>, | |
| ) => Promise<TData | undefined>; | |
| mutateAsync: MutationObject<TData, TError, TVariables, TContext>['mutate']; | |
| reset: () => void; | |
| destroy: () => void; | |
| destroyDisposer: () => void; | |
| addInstance: () => () => void; | |
| removeInstance: () => void; | |
| scheduleDestroy: () => void; | |
| instances: number; | |
| }; | |
| export type MutationKey = FunctionMaybe<ObservableMaybe<string | number>[]>; | |
| export type MutationOptions< | |
| TData = unknown, | |
| TError = unknown, | |
| TVariables = TData, | |
| TContext = unknown, | |
| > = { | |
| mutationFn?: (variables: TVariables) => Promise<TData>; | |
| mutationKey?: MutationKey; | |
| onMutate?: (variables: TVariables) => Promise<TContext> | TContext; | |
| onSuccess?: ( | |
| data: TData, | |
| variables: TVariables, | |
| context: TContext, | |
| ) => Promise<unknown> | unknown; | |
| onError?: ( | |
| error: TError, | |
| variables: TVariables, | |
| context: TContext | undefined, | |
| ) => Promise<unknown> | unknown; | |
| onSettled?: ( | |
| data: TData | undefined, | |
| error: TError | null, | |
| variables: TVariables, | |
| context: TContext | undefined, | |
| ) => Promise<unknown> | unknown; | |
| retry?: boolean | number | ((failureCount: number, error: TError) => boolean); | |
| retryDelay?: number | ((retryAttempt: number, error: TError) => number); | |
| gcTime?: number; | |
| networkMode?: 'online' | 'always' | 'offlineFirst'; | |
| throwOnError?: boolean | ((error: TError) => boolean); | |
| meta?: Record<string, unknown>; | |
| queryClient?: QueryClient; | |
| }; | |
| type MutateOptions<TData, TError, TVariables, TContext> = { | |
| onSuccess?: (data: TData, variables: TVariables, context: TContext) => void; | |
| onError?: ( | |
| error: TError, | |
| variables: TVariables, | |
| context: TContext | undefined, | |
| ) => void; | |
| onSettled?: ( | |
| data: TData | undefined, | |
| error: TError | null, | |
| variables: TVariables, | |
| context: TContext | undefined, | |
| ) => void; | |
| }; | |
| export type Mutation< | |
| TData = unknown, | |
| TError = unknown, | |
| TVariables = unknown, | |
| TContext = unknown, | |
| > = { | |
| data: Observable<TData | undefined>; | |
| error: Observable<TError | null>; | |
| isError: Observable<boolean>; | |
| isIdle: Observable<boolean>; | |
| isPending: Observable<boolean>; | |
| isPaused: Observable<boolean>; | |
| isSuccess: Observable<boolean>; | |
| failureCount: Observable<number>; | |
| failureReason: Observable<TError | null>; | |
| status: Observable<MutationStatus>; | |
| submittedAt: Observable<number | undefined>; | |
| variables: Observable<TVariables | undefined>; | |
| meta: Observable<Record<string, unknown>>; | |
| mutate: ( | |
| variables: TVariables, | |
| options?: MutateOptions<TData, TError, TVariables, TContext>, | |
| ) => Promise<TData | undefined>; | |
| mutateAsync: ( | |
| variables: TVariables, | |
| options?: MutateOptions<TData, TError, TVariables, TContext>, | |
| ) => Promise<TData | undefined>; | |
| reset: () => void; | |
| }; | |
| function createMutation< | |
| TData, | |
| TError = Error, | |
| TVariables = void, | |
| TContext = unknown, | |
| >( | |
| queryClient: QueryClient, | |
| options: MutationOptions<TData, TError, TVariables, TContext>, | |
| ): MutationObject<TData, TError, TVariables, TContext> { | |
| const mutationKey = options.mutationKey | |
| ? hashFn(options.mutationKey) | |
| : undefined; | |
| if (mutationKey && queryClient.mutationCache.has(mutationKey)) { | |
| return queryClient.mutationCache.get(mutationKey) as MutationObject< | |
| TData, | |
| TError, | |
| TVariables, | |
| TContext | |
| >; | |
| } | |
| const resolvedOptions = { | |
| ...(queryClient.getDefaultOptions().mutations as MutationOptions< | |
| TData, | |
| TError, | |
| TVariables, | |
| TContext | |
| >), | |
| ...(queryClient.getMutationDefaults(options.mutationKey) as MutationOptions< | |
| TData, | |
| TError, | |
| TVariables, | |
| TContext | |
| >), | |
| ...options, | |
| }; | |
| const shouldRetry = (failureCount: number, error: TError): boolean => { | |
| if (!resolvedOptions.retry) return false; | |
| if (typeof resolvedOptions.retry === 'function') { | |
| return resolvedOptions.retry(failureCount, error); | |
| } | |
| return typeof resolvedOptions.retry === 'boolean' | |
| ? resolvedOptions.retry | |
| : resolvedOptions.retry > failureCount; | |
| }; | |
| const state: MutationState<TData, TError, TVariables, TContext> = { | |
| data: $(undefined), | |
| error: $<TError | null>(null), | |
| status: $<MutationStatus>('idle'), | |
| failureCount: $(0), | |
| failureReason: $<TError | null>(null), | |
| isPaused: $(false), | |
| submittedAt: $(undefined), | |
| variables: $(undefined), | |
| isError: useMemo(() => state.status() === 'error'), | |
| isIdle: useMemo(() => state.status() === 'idle'), | |
| isPending: useMemo(() => state.status() === 'pending'), | |
| isSuccess: useMemo(() => state.status() === 'success'), | |
| meta: $({}), | |
| }; | |
| const mutate = async ( | |
| variables: TVariables, | |
| mutateOptions?: MutateOptions<TData, TError, TVariables, TContext>, | |
| ) => { | |
| let context: TContext | undefined; | |
| state.status('pending'); | |
| state.variables(variables); | |
| state.submittedAt(Date.now()); | |
| try { | |
| if (resolvedOptions.onMutate) { | |
| context = await resolvedOptions.onMutate(variables); | |
| } | |
| const data = await resolvedOptions.mutationFn!(variables); | |
| state.status('success'); | |
| state.data(data); | |
| await resolvedOptions.onSuccess?.(data, variables, context as TContext); | |
| mutateOptions?.onSuccess?.(data, variables, context as TContext); | |
| await resolvedOptions.onSettled?.(data, null, variables, context); | |
| mutateOptions?.onSettled?.(data, null, variables, context); | |
| return data; | |
| } catch (error) { | |
| state.status('error'); | |
| state.error(error as TError); | |
| state.failureCount((count) => count + 1); | |
| state.failureReason(error as TError); | |
| await resolvedOptions.onError?.(error as TError, variables, context); | |
| mutateOptions?.onError?.(error as TError, variables, context); | |
| await resolvedOptions.onSettled?.( | |
| undefined, | |
| error as TError, | |
| variables, | |
| context, | |
| ); | |
| mutateOptions?.onSettled?.( | |
| undefined, | |
| error as TError, | |
| variables, | |
| context, | |
| ); | |
| if (shouldRetry(state.failureCount(), error as TError)) { | |
| const retryDelay = resolvedOptions.retryDelay ?? 1000; | |
| const maxRetries = | |
| typeof resolvedOptions.retry === 'number' ? resolvedOptions.retry : 3; | |
| if (state.failureCount() <= maxRetries) { | |
| const resolvedRetryDelay = | |
| typeof retryDelay === 'function' | |
| ? retryDelay(state.failureCount(), error as TError) | |
| : retryDelay; | |
| useTimeout( | |
| () => { | |
| mutate(variables, mutateOptions); | |
| }, | |
| resolvedRetryDelay * 2 ** (state.failureCount() - 1), | |
| ); | |
| } else { | |
| if (resolvedOptions.throwOnError) { | |
| throw error; | |
| } | |
| } | |
| } else { | |
| if (resolvedOptions.throwOnError) { | |
| throw error; | |
| } | |
| } | |
| } | |
| return state.data(); | |
| }; | |
| const reset = () => { | |
| state.status('idle'); | |
| state.data(undefined); | |
| state.error(null); | |
| state.failureCount(0); | |
| state.failureReason(null); | |
| state.isPaused(false); | |
| state.submittedAt(undefined); | |
| state.variables(undefined); | |
| if (mutationKey) { | |
| queryClient.mutationCache.delete(mutationKey); | |
| } | |
| }; | |
| const mutationObject: MutationObject<TData, TError, TVariables, TContext> = { | |
| instances: 0, | |
| state, | |
| resolvedOptions, | |
| mutate, | |
| mutateAsync: mutate, | |
| reset, | |
| destroy: () => { | |
| if (mutationKey) { | |
| queryClient.mutationCache.delete(mutationKey); | |
| } | |
| }, | |
| addInstance: () => { | |
| mutationObject.destroyDisposer(); | |
| mutationObject.instances++; | |
| return mutationObject.removeInstance; | |
| }, | |
| removeInstance: () => { | |
| mutationObject.instances--; | |
| if (mutationObject.instances === 0) { | |
| mutationObject.scheduleDestroy(); | |
| } | |
| }, | |
| scheduleDestroy: () => { | |
| useRoot(() => { | |
| mutationObject.destroyDisposer = useTimeout( | |
| () => { | |
| mutationObject.destroy(); | |
| }, | |
| resolvedOptions.gcTime ?? 5 * 60 * 1000, | |
| ); // Default to 5 minutes if gcTime is not provided | |
| }); | |
| }, | |
| destroyDisposer: () => {}, | |
| }; | |
| if (mutationKey) { | |
| queryClient.mutationCache.set(mutationKey, mutationObject); | |
| } | |
| return mutationObject; | |
| } | |
| export function useMutation< | |
| TData, | |
| TError = Error, | |
| TVariables = void, | |
| TContext = unknown, | |
| >( | |
| options: MutationOptions<TData, TError, TVariables, TContext>, | |
| ): ObservableReadonly< | |
| { | |
| [K in keyof Omit< | |
| MutationState<TData, TError, TVariables, TContext>, | |
| 'meta' | |
| >]: ObservableReadonly< | |
| ReturnType<MutationState<TData, TError, TVariables, TContext>[K]> | |
| >; | |
| } & { | |
| meta: MutationState<TData, TError, TVariables, TContext>['meta']; | |
| } & Pick< | |
| Mutation<TData, TError, TVariables, TContext>, | |
| 'mutate' | 'mutateAsync' | 'reset' | |
| > | |
| > { | |
| const queryClient = useQueryClient(options.queryClient); | |
| const mutation = useMemo(() => createMutation(queryClient, options)); | |
| useEffect(() => mutation().addInstance()); | |
| return useMemo(() => ({ | |
| data: useMemo(() => mutation().state.data()), | |
| error: useMemo(() => mutation().state.error()), | |
| isError: useMemo(() => mutation().state.isError()), | |
| isIdle: useMemo(() => mutation().state.isIdle()), | |
| isPending: useMemo(() => mutation().state.isPending()), | |
| isSuccess: useMemo(() => mutation().state.isSuccess()), | |
| isPaused: useMemo(() => mutation().state.isPaused()), | |
| failureCount: useMemo(() => mutation().state.failureCount()), | |
| failureReason: useMemo(() => mutation().state.failureReason()), | |
| mutate: mutation().mutate, | |
| mutateAsync: mutation().mutateAsync, | |
| reset: mutation().reset, | |
| status: useMemo(() => mutation().state.status()), | |
| submittedAt: useMemo(() => mutation().state.submittedAt()), | |
| variables: useMemo(() => mutation().state.variables()), | |
| meta: mutation().state.meta, | |
| })); | |
| } | |
| export type MutationFilters = { | |
| mutationKey?: QueryKey; | |
| exact?: boolean; | |
| status?: MutationStatus; | |
| }; | |
| type MutationStateOptions<TResult = MutationState> = { | |
| filters?: MutationFilters; | |
| select?: (mutation: MutationObject<any, any, any, any>) => TResult; | |
| }; | |
| export function useMutationState<TResult = MutationState>({ | |
| filters, | |
| select, | |
| }: MutationStateOptions<TResult>): ObservableReadonly<TResult[]> { | |
| const queryClient = useQueryClient(); | |
| const mutationHash = | |
| filters?.mutationKey && JSON.stringify(filters?.mutationKey); | |
| const cache = queryClient.mutationCache; | |
| return useMemo(() => { | |
| return Array.from(mutationHash ? [cache.get(mutationHash)] : cache.values()) | |
| .filter( | |
| (mutation): mutation is MutationObject<any, any, any, any> => | |
| mutation !== undefined && | |
| (filters?.status ? mutation.state.status() === filters.status : true), | |
| ) | |
| .map((mutation) => | |
| select ? select(mutation) : (mutation as unknown as TResult), | |
| ); | |
| }) as ObservableReadonly<TResult[]>; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { | |
| $, | |
| $$, | |
| type FunctionMaybe, | |
| type Observable, | |
| type ObservableMaybe, | |
| type ObservableReadonly, | |
| untrack, | |
| useContext, | |
| useEffect, | |
| useEventListener, | |
| useInterval, | |
| useMemo, | |
| useReadonly, | |
| useRoot, | |
| useTimeout, | |
| } from 'voby'; | |
| import { QueryClientContext } from './context.ts'; | |
| import type { | |
| MutationFilters, | |
| MutationKey, | |
| MutationObject, | |
| MutationOptions, | |
| } from './useMutation.ts'; | |
| import { hashFn } from './utils.ts'; | |
| // #region Types | |
| export type QueryClient = { | |
| cache: Map<string, Query<any, any, any, any>>; | |
| mutationCache: Map<string, MutationObject<any, any, any, any>>; | |
| jobQueue: Map<string, number[]>; | |
| startQueueJob: (queueKey: string) => void; | |
| finishQueueJob: (queueKey: string) => void; | |
| getQueryData: <T>(queryKey: QueryKey) => T; | |
| setQueryData: <T>(queryKey: QueryKey, data: (previous: T) => T) => void; | |
| invalidateQueries: ( | |
| filters: { | |
| queryKey: QueryKey; | |
| exact?: boolean; | |
| refetchType?: 'active' | 'inactive' | 'all' | 'none'; | |
| }, | |
| options?: { | |
| throwOnError?: boolean; | |
| cancelRefetch?: boolean; | |
| }, | |
| ) => Promise<void>; | |
| ensureQueryData: <T>(options: QueryOptions<T>) => Promise<T>; | |
| fetchQuery: <T>(options: QueryOptions<T>) => Promise<T>; | |
| prefetchQuery: <T>(options: QueryOptions<T>) => Promise<void>; | |
| refetchQueries: ( | |
| filters?: { | |
| queryKey?: QueryKey; | |
| type?: 'all' | 'active' | 'inactive'; | |
| exact?: boolean; | |
| stale?: boolean; | |
| }, | |
| options?: { | |
| throwOnError?: boolean; | |
| cancelRefetch?: boolean; | |
| }, | |
| ) => Promise<void>; | |
| cancelQueries: (filters?: { | |
| queryKey?: QueryKey; | |
| exact?: boolean; | |
| }) => Promise<void>; | |
| removeQueries: (filters?: { | |
| queryKey?: QueryKey; | |
| exact?: boolean; | |
| }) => void; | |
| resetQueries: ( | |
| filters?: { | |
| queryKey?: QueryKey; | |
| exact?: boolean; | |
| }, | |
| options?: { | |
| throwOnError?: boolean; | |
| cancelRefetch?: boolean; | |
| }, | |
| ) => void; | |
| isFetching: (filters?: { | |
| queryKey?: QueryKey; | |
| exact?: boolean; | |
| }) => number; | |
| isMutating: (filters?: MutationFilters) => number; | |
| getQueryCache: () => Map<string, Query>; | |
| getMutationCache: () => Map<string, MutationObject>; | |
| clear: () => void; | |
| getDefaultOptions: () => { | |
| queries: Omit<QueryOptions, 'queryKey'>; | |
| mutations: MutationOptions; | |
| }; | |
| setDefaultOptions: (options: { | |
| queries?: Partial<QueryOptions>; | |
| mutations?: Partial<MutationOptions>; | |
| }) => void; | |
| getQueryDefaults: (queryKey: QueryKey) => Partial<QueryOptions>; | |
| setQueryDefaults: ( | |
| queryKey: QueryKey, | |
| defaults: Partial<QueryOptions>, | |
| ) => void; | |
| getMutationDefaults: (mutationKey?: MutationKey) => Partial<MutationOptions>; | |
| setMutationDefaults: ( | |
| mutationKey: MutationKey, | |
| defaults: Partial<MutationOptions>, | |
| ) => void; | |
| }; | |
| export type QueryKey = FunctionMaybe<ObservableMaybe<string | number>[]>; | |
| export type QueryOptions< | |
| TQueryFnData = unknown, | |
| TError = unknown, | |
| TData = TQueryFnData, | |
| TQueryKey extends QueryKey = QueryKey, | |
| TInitialData extends TQueryFnData | undefined = undefined, | |
| R = void, | |
| > = { | |
| queryKey: TQueryKey; | |
| queryFn?: (options: { signal: AbortSignal }) => Promise<TQueryFnData>; | |
| queryClient?: QueryClient; | |
| initialData?: TInitialData; | |
| initialDataUpdatedAt?: number; | |
| placeholderData?: TData; | |
| enabled?: FunctionMaybe<boolean>; | |
| staleTime?: number; | |
| refetchInterval?: number; | |
| gcTime?: number; | |
| throwOnError?: boolean; | |
| select?: ( | |
| data: TInitialData extends TQueryFnData ? TInitialData : TQueryFnData, | |
| ) => R; | |
| networkMode?: 'online' | 'always' | 'offlineFirst'; | |
| refetchOnReconnect?: boolean; | |
| retry?: boolean | number; | |
| retryOnMount?: boolean; | |
| retryDelay?: number; | |
| cancelRefetch?: boolean; | |
| refetchOnWindowFocus?: boolean | 'always'; | |
| refetchOnMount?: | |
| | boolean | |
| | 'always' | |
| | ((query: Query<any, any, any, any, any, any>) => boolean | 'always'); | |
| queryKeyHashFn?: (queryKey: QueryKey) => string; | |
| }; | |
| export type QueryStatus = 'pending' | 'error' | 'success'; | |
| export type FetchStatus = 'fetching' | 'paused' | 'idle'; | |
| type QueryState<D = undefined> = { | |
| data: Observable<D>; | |
| dataUpdateCount: Observable<number>; | |
| dataUpdatedAt: Observable<number>; | |
| error: Observable<Error | null>; | |
| errorUpdateCount: Observable<number>; | |
| errorUpdatedAt: Observable<number>; | |
| meta: Observable<null>; | |
| isInvalidated: Observable<boolean>; | |
| status: Observable<QueryStatus>; | |
| fetchStatus: Observable<FetchStatus>; | |
| isFetching: ObservableReadonly<boolean>; | |
| isRefetching: ObservableReadonly<boolean>; | |
| isFetched: ObservableReadonly<boolean>; | |
| isFetchedAfterMount: ObservableReadonly<boolean>; | |
| isPaused: ObservableReadonly<boolean>; | |
| isPending: ObservableReadonly<boolean>; | |
| isSuccess: ObservableReadonly<boolean>; | |
| isError: ObservableReadonly<boolean>; | |
| isLoadingError: ObservableReadonly<boolean>; | |
| isRefetchError: ObservableReadonly<boolean>; | |
| isStale: Observable<boolean>; | |
| }; | |
| type QueryStateReadonly<D> = { | |
| [K in keyof Omit<QueryState<D>, 'meta'>]: ObservableReadonly< | |
| Awaited<ReturnType<QueryState<D>[K]>> | |
| >; | |
| } & { meta: QueryState<D>['meta'] }; | |
| type Query< | |
| TQueryFnData = unknown, | |
| TError = unknown, | |
| TData = TQueryFnData, | |
| TQueryKey extends QueryKey = QueryKey, | |
| TInitialData extends TQueryFnData | undefined = undefined, | |
| R = void, | |
| > = { | |
| isActive: boolean; | |
| state: QueryState<TData>; | |
| cancel: () => Promise<void>; | |
| destroy: () => void; | |
| fetch: (retryAttempt?: number, throwOnError?: boolean) => Promise<void>; | |
| refetch: (options?: { | |
| throwOnError?: boolean; | |
| cancelRefetch?: boolean; | |
| }) => Promise<void>; | |
| resolvedOptions: QueryOptions< | |
| TQueryFnData, | |
| TError, | |
| TData, | |
| TQueryKey, | |
| TInitialData, | |
| R | |
| >; | |
| instances: number; | |
| controller: AbortController; | |
| isFetching: boolean; | |
| destroyDisposer: () => void; | |
| staleDisposer: () => void; | |
| addInstance: () => () => void; | |
| removeInstance: () => void; | |
| scheduleDestory: () => void; | |
| reset: () => void; | |
| scheduleRetry: (retryAttempt: number) => void; | |
| isCancelled: boolean; | |
| events: EventTarget; | |
| }; | |
| // #region Core | |
| const createQueryCache = (cache?: Map<string, Query<any, any, any, any>>) => { | |
| return cache ?? new Map<string, Query<any, any, any, any>>(); | |
| }; | |
| const createMutationCache = ( | |
| cache?: Map<string, MutationObject<any, any, any, any>>, | |
| ) => { | |
| return cache ?? new Map<string, MutationObject<any, any, any, any>>(); | |
| }; | |
| // #region createQuery | |
| const createQuery = < | |
| TQueryFnData = unknown, | |
| TError = unknown, | |
| TData = TQueryFnData, | |
| TQueryKey extends QueryKey = QueryKey, | |
| TInitialData extends TQueryFnData | undefined = undefined, | |
| R = void, | |
| >( | |
| queryClient: QueryClient, | |
| options: QueryOptions< | |
| TQueryFnData, | |
| TError, | |
| TData, | |
| TQueryKey, | |
| TInitialData, | |
| R | |
| >, | |
| ): Query<TQueryFnData, TError, TData, TQueryKey, TInitialData, R> => { | |
| const resolvedOptions: QueryOptions< | |
| TQueryFnData, | |
| TError, | |
| TData, | |
| TQueryKey, | |
| TInitialData, | |
| R | |
| > = { | |
| queryClient, | |
| ...(queryClient.getDefaultOptions().queries as QueryOptions< | |
| TQueryFnData, | |
| TError, | |
| TData, | |
| TQueryKey, | |
| TInitialData, | |
| R | |
| >), | |
| ...(queryClient.getQueryDefaults(options.queryKey) as QueryOptions< | |
| TQueryFnData, | |
| TError, | |
| TData, | |
| TQueryKey, | |
| TInitialData, | |
| R | |
| >), | |
| ...options, | |
| }; | |
| const cache = queryClient.cache; | |
| const queryHash = resolvedOptions.queryKeyHashFn!(options.queryKey); | |
| $$(options.enabled); | |
| if (cache.has(queryHash)) { | |
| const query = cache.get(queryHash) as Query< | |
| TQueryFnData, | |
| TError, | |
| TData, | |
| TQueryKey, | |
| TInitialData, | |
| R | |
| >; | |
| query.resolvedOptions = resolvedOptions; | |
| return query as Query< | |
| TQueryFnData, | |
| TError, | |
| TData, | |
| TQueryKey, | |
| TInitialData, | |
| R | |
| >; | |
| } | |
| // #region state | |
| const state = { | |
| data: $(options.initialData as TData, { equals: false }), | |
| dataUpdateCount: $(options.initialDataUpdatedAt ?? 0), | |
| dataUpdatedAt: $(Date.now()), | |
| error: $<Error | null>(null, { equals: false }), | |
| errorUpdateCount: $(0), | |
| errorUpdatedAt: $(Date.now()), | |
| meta: $(null), | |
| isInvalidated: $(false), | |
| status: $<QueryStatus>('pending'), | |
| fetchStatus: $<FetchStatus>('idle'), | |
| isFetching: useMemo((): boolean => state.fetchStatus() === 'fetching'), | |
| isRefetching: useMemo( | |
| (): boolean => | |
| state.fetchStatus() === 'fetching' && state.status() !== 'pending', | |
| ), | |
| isFetched: useMemo((): boolean => state.fetchStatus() === 'idle'), | |
| isFetchedAfterMount: useMemo((): boolean => state.status() !== 'pending'), | |
| isPaused: useMemo((): boolean => state.fetchStatus() === 'paused'), | |
| isPending: useMemo((): boolean => state.status() === 'pending'), | |
| isSuccess: useMemo((): boolean => state.status() === 'success'), | |
| isError: useMemo((): boolean => state.status() === 'error'), | |
| isLoadingError: useMemo( | |
| (): boolean => state.status() === 'error' && state.status() === 'pending', | |
| ), | |
| isRefetchError: useMemo( | |
| (): boolean => state.status() === 'error' && state.status() !== 'pending', | |
| ), | |
| isStale: $(false), | |
| } satisfies QueryState<TData>; | |
| // create custom event | |
| const events = new EventTarget(); | |
| // #region query | |
| const query: Query<TQueryFnData, TError, TData, TQueryKey, TInitialData, R> = | |
| { | |
| isActive: false, | |
| events, | |
| resolvedOptions, | |
| instances: 0, | |
| state, | |
| controller: new AbortController(), | |
| isCancelled: false, | |
| destroyDisposer: () => {}, | |
| // #region addInstance | |
| addInstance: () => { | |
| query.destroyDisposer(); | |
| query.isActive = true; | |
| query.instances++; | |
| untrack(() => { | |
| if (query.resolvedOptions.enabled) { | |
| const shouldRefetch = (() => { | |
| const refetchOnMount = resolvedOptions.refetchOnMount; | |
| if (typeof refetchOnMount === 'function') { | |
| return refetchOnMount(query); | |
| } | |
| if (refetchOnMount === 'always') return true; | |
| if (refetchOnMount === false) return false; | |
| return query.state.isStale() || query.resolvedOptions.enabled; | |
| })(); | |
| if (shouldRefetch) { | |
| query.fetch(); | |
| } | |
| } | |
| if (state.fetchStatus() !== 'fetching') { | |
| state.fetchStatus(window.navigator.onLine ? 'idle' : 'paused'); | |
| } | |
| if (resolvedOptions.refetchInterval) { | |
| useInterval(() => { | |
| query.fetch(); | |
| }, resolvedOptions.refetchInterval); | |
| } | |
| if (resolvedOptions.networkMode === 'online') { | |
| useEventListener(window, 'online', async () => { | |
| if (resolvedOptions.refetchOnReconnect) { | |
| state.fetchStatus('fetching'); | |
| query.refetch(); | |
| } | |
| }); | |
| useEventListener(window, 'offline', async () => { | |
| query.cancel(); | |
| state.fetchStatus('paused'); | |
| }); | |
| } | |
| if (resolvedOptions.refetchOnWindowFocus) { | |
| useEventListener(document, 'visibilitychange', () => { | |
| if (document.visibilityState === 'visible' && state.isStale()) { | |
| query.fetch(); | |
| } | |
| }); | |
| } | |
| }); | |
| return query.removeInstance; | |
| }, | |
| removeInstance: () => { | |
| query.instances--; | |
| if (query.instances === 0) { | |
| query.isActive = false; | |
| query.scheduleDestory(); | |
| } | |
| }, | |
| // #region cancel | |
| cancel: async () => { | |
| return new Promise((resolve) => { | |
| query.controller.abort(); | |
| query.isCancelled = true; | |
| requestAnimationFrame(() => { | |
| resolve(); | |
| }); | |
| }); | |
| }, | |
| reset: () => { | |
| query.state.data(options.initialData as TData); | |
| query.state.dataUpdatedAt(options.initialDataUpdatedAt ?? 0); | |
| query.state.error(null); | |
| query.state.errorUpdatedAt(0); | |
| query.state.status('pending'); | |
| query.state.fetchStatus('idle'); | |
| query.state.isStale(false); | |
| }, | |
| scheduleDestory: () => { | |
| useRoot(() => { | |
| query.destroyDisposer = useTimeout(() => { | |
| query.destroy(); | |
| }, resolvedOptions.gcTime); | |
| }); | |
| }, | |
| destroy: () => { | |
| query.cancel(); | |
| cache.delete(queryHash); | |
| }, | |
| isFetching: false, | |
| refetch: async ({ | |
| throwOnError = resolvedOptions.throwOnError, | |
| cancelRefetch = resolvedOptions.cancelRefetch, | |
| } = {}) => { | |
| if (cancelRefetch) { | |
| await query.cancel(); | |
| } | |
| return query.fetch(0, throwOnError); | |
| }, | |
| // #region fetch | |
| fetch: async ( | |
| retryAttempt = 0, | |
| throwOnError = resolvedOptions.throwOnError, | |
| ) => { | |
| if (!query.isActive) return; | |
| if (query.isFetching && !query.isCancelled) return; | |
| if (state.fetchStatus() === 'paused') return; | |
| query.isFetching = true; | |
| query.isCancelled = false; | |
| query.controller = new AbortController(); | |
| const signal = query.controller.signal; | |
| try { | |
| state.fetchStatus('fetching'); | |
| const result = await untrack(() => | |
| query.resolvedOptions.queryFn!({ signal }), | |
| ); | |
| // If query is cancelled before promise resolves, don't update state | |
| if (query.isCancelled || signal.aborted) { | |
| return; | |
| } | |
| state.data(result as TData); | |
| state.dataUpdatedAt(Date.now()); | |
| state.dataUpdateCount(state.dataUpdateCount() + 1); | |
| state.status('success'); | |
| } catch (error) { | |
| if (error instanceof Error && !signal.aborted) { | |
| state.error(error); | |
| state.status('error'); | |
| state.errorUpdatedAt(Date.now()); | |
| state.errorUpdateCount(state.errorUpdateCount() + 1); | |
| if (throwOnError) { | |
| throw error; | |
| } | |
| query.scheduleRetry(retryAttempt + 1); | |
| } | |
| } finally { | |
| if ( | |
| !query.isCancelled && | |
| signal.aborted && | |
| state.fetchStatus() === 'fetching' | |
| ) { | |
| // biome-ignore lint/correctness/noUnsafeFinally: <explanation> | |
| return; | |
| } | |
| query.isFetching = false; | |
| if (state.fetchStatus() !== 'paused') { | |
| state.fetchStatus('idle'); | |
| } | |
| query.staleDisposer(); | |
| state.isStale(false); | |
| query.staleDisposer = useTimeout(() => { | |
| state.isStale(true); | |
| }, resolvedOptions.staleTime); | |
| events.dispatchEvent(new CustomEvent('fetch:done')); | |
| } | |
| }, | |
| staleDisposer: () => {}, | |
| // #region retry | |
| scheduleRetry: (attempt: number) => { | |
| const { retry, retryDelay, retryOnMount } = resolvedOptions; | |
| if (retry === false) return; | |
| if ( | |
| query.resolvedOptions.networkMode === 'online' && | |
| state.fetchStatus() === 'paused' | |
| ) { | |
| useEventListener( | |
| window, | |
| 'online', | |
| () => { | |
| query.fetch(attempt); | |
| }, | |
| { once: true }, | |
| ); | |
| return; | |
| } | |
| if (retry === true) { | |
| useTimeout(() => { | |
| query.fetch(); | |
| }, retryDelay); | |
| } else if (retry && attempt < retry) { | |
| useTimeout(() => { | |
| query.fetch(attempt); | |
| }, retryDelay); | |
| } | |
| }, | |
| }; | |
| cache.set(queryHash, query as Query); | |
| return query; | |
| }; | |
| export type QueryFilters = { | |
| queryKey?: QueryKey; | |
| exact?: boolean; | |
| type?: 'all' | 'active' | 'inactive'; | |
| stale?: boolean; | |
| fetchStatus?: FetchStatus; | |
| }; | |
| type FetchQueryOptions< | |
| TQueryFnData = unknown, | |
| TError = unknown, | |
| TData = TQueryFnData, | |
| TQueryKey extends QueryKey = QueryKey, | |
| > = Omit< | |
| QueryOptions<TQueryFnData, TError, TData, TQueryKey>, | |
| | 'enabled' | |
| | 'refetchInterval' | |
| | 'refetchIntervalInBackground' | |
| | 'refetchOnWindowFocus' | |
| | 'refetchOnReconnect' | |
| | 'refetchOnMount' | |
| | 'throwOnError' | |
| | 'select' | |
| | 'suspense' | |
| | 'placeholderData' | |
| >; | |
| // #region createQueryClient | |
| export const createQueryClient = (options?: { | |
| queryCache?: Map<string, Query>; | |
| mutationCache?: Map<string, MutationObject>; | |
| jobQueue?: Map<string, number[]>; | |
| defaultOptions?: { | |
| queries?: Omit<QueryOptions, 'queryKey'>; | |
| mutations?: MutationOptions; | |
| }; | |
| }): QueryClient => { | |
| const queryDefaults = options?.defaultOptions?.queries ?? { | |
| queryKeyHashFn: hashFn, | |
| enabled: true, | |
| throwOnError: false, | |
| gcTime: 1000 * 60 * 5, | |
| staleTime: 1000 * 60 * 5, | |
| refetchInterval: 1000 * 60 * 5, | |
| networkMode: 'online' as const, | |
| retry: 3, | |
| retryOnMount: true, | |
| retryDelay: 1000, | |
| cancelRefetch: true, | |
| refetchOnWindowFocus: true, | |
| refetchOnReconnect: options?.defaultOptions?.queries?.networkMode | |
| ? options?.defaultOptions?.queries?.networkMode === 'online' | |
| : true, | |
| refetchOnMount: true, | |
| }; | |
| const mutationDefaults = options?.defaultOptions?.mutations ?? { | |
| retry: 0, | |
| retryDelay: 0, | |
| gcTime: 5 * 60 * 1000, | |
| networkMode: 'online' as const, | |
| throwOnError: false, | |
| }; | |
| const getDefaultOptions = () => ({ | |
| queries: queryDefaults, | |
| mutations: mutationDefaults, | |
| }); | |
| const setDefaultOptions = (newOptions: { | |
| queries?: Partial<typeof queryDefaults>; | |
| mutations?: Partial<typeof mutationDefaults>; | |
| }) => { | |
| Object.assign(queryDefaults, newOptions.queries); | |
| Object.assign(mutationDefaults, newOptions.mutations); | |
| }; | |
| const queryDefaultsMap = new Map<string, Partial<QueryOptions>>(); | |
| const getQueryDefaults = (queryKey: QueryKey) => { | |
| const queryHash = queryKeyHashFn(queryKey); | |
| for (const [key, defaults] of queryDefaultsMap.entries()) { | |
| if (queryHash.startsWith(key)) { | |
| return defaults; | |
| } | |
| } | |
| return {}; | |
| }; | |
| const setQueryDefaults = ( | |
| queryKey: QueryKey, | |
| defaults: Partial<QueryOptions>, | |
| ) => { | |
| const queryHash = queryKeyHashFn(queryKey); | |
| queryDefaultsMap.set(queryHash, defaults); | |
| }; | |
| const mutationDefaultsMap = new Map<string, Partial<MutationOptions>>(); | |
| const getMutationDefaults = (mutationKey?: MutationKey) => { | |
| if (mutationKey) { | |
| const mutationHash = queryKeyHashFn(mutationKey); | |
| for (const [key, defaults] of mutationDefaultsMap.entries()) { | |
| if (mutationHash.startsWith(key)) { | |
| return defaults; | |
| } | |
| } | |
| } | |
| return {}; | |
| }; | |
| const setMutationDefaults = ( | |
| mutationKey: MutationKey, | |
| defaults: Partial<MutationOptions>, | |
| ) => { | |
| const mutationHash = queryKeyHashFn(mutationKey); | |
| mutationDefaultsMap.set(mutationHash, defaults); | |
| }; | |
| const queryKeyHashFn = queryDefaults.queryKeyHashFn ?? hashFn; | |
| const cache = createQueryCache(options?.queryCache); | |
| const mutationCache = createMutationCache(options?.mutationCache); | |
| const jobQueue = options?.jobQueue ?? new Map(); | |
| const queueBus = new EventTarget(); | |
| const startQueueJob = async (queueKey: string) => { | |
| const queue = jobQueue.get(queueKey) ?? []; | |
| const queueId = Date.now(); | |
| queue.push(queueId); | |
| jobQueue.set(queueKey, queue); | |
| if (queue[0] === queueId) return; | |
| await new Promise((resolve) => { | |
| const event = () => { | |
| if (queue[0] === queueId) { | |
| resolve(undefined); | |
| queueBus.removeEventListener('queue:updated', event); | |
| } | |
| }; | |
| queueBus.addEventListener('queue:updated', event); | |
| }); | |
| }; | |
| const finishQueueJob = (queueKey: string) => { | |
| const queue = jobQueue.get(queueKey); | |
| if (!queue) return; | |
| queue.shift(); | |
| if (queue.length === 0) { | |
| jobQueue.delete(queueKey); | |
| } else { | |
| queueBus.dispatchEvent(new CustomEvent('queue:updated')); | |
| } | |
| }; | |
| const getQueryData = (queryKey: QueryKey) => { | |
| const queryHash = queryKeyHashFn(queryKey); | |
| return cache.get(queryHash)?.state.data(); | |
| }; | |
| // #region setQueryData | |
| const setQueryData = (queryKey: QueryKey, data: any) => { | |
| const queryHash = queryKeyHashFn(queryKey); | |
| const query = cache.get(queryHash); | |
| if (!query) return; | |
| query.state.data((old) => (typeof data === 'function' ? data(old) : data)); | |
| }; | |
| // #region invalidateQueries | |
| const invalidateQueries: QueryClient['invalidateQueries'] = async ( | |
| { queryKey, exact = false, refetchType = 'active' }, | |
| { throwOnError = false, cancelRefetch = true } = {}, | |
| ) => { | |
| const queriesToInvalidate = Array.from(cache.values()).filter((query) => { | |
| if (queryKey) { | |
| const currentQueryHash = queryKeyHashFn(query.resolvedOptions.queryKey); | |
| const filterQueryHash = queryKeyHashFn(queryKey); | |
| const keyMatch = exact | |
| ? currentQueryHash === filterQueryHash | |
| : currentQueryHash.startsWith(filterQueryHash); | |
| if (!keyMatch) return false; | |
| } | |
| return true; | |
| }); | |
| for (const query of queriesToInvalidate) { | |
| query.state.isStale(true); | |
| } | |
| if (refetchType === 'none') return; | |
| const queriesToRefetch = queriesToInvalidate.filter((query) => { | |
| if (refetchType === 'active' && !query.isActive) return false; | |
| if (refetchType === 'inactive' && query.isActive) return false; | |
| return true; | |
| }); | |
| if (cancelRefetch) { | |
| for (const query of queriesToRefetch) { | |
| query.cancel(); | |
| } | |
| } | |
| const refetchPromises = queriesToRefetch.map((query) => | |
| query.fetch(undefined, throwOnError), | |
| ); | |
| try { | |
| await Promise.all(refetchPromises); | |
| } catch (error) { | |
| if (throwOnError) { | |
| throw error; | |
| } | |
| } | |
| }; | |
| // #region refetchQueries | |
| const refetchQueries = async ( | |
| filters?: { | |
| queryKey?: QueryKey; | |
| type?: 'all' | 'active' | 'inactive'; | |
| exact?: boolean; | |
| stale?: boolean; | |
| }, | |
| options?: { | |
| throwOnError?: boolean; | |
| cancelRefetch?: boolean; | |
| }, | |
| ): Promise<void> => { | |
| const { queryKey, type = 'all', exact = false, stale } = filters || {}; | |
| const { throwOnError = false, cancelRefetch = true } = options || {}; | |
| const queriesToRefetch = Array.from(cache.values()).filter((query) => { | |
| if (queryKey) { | |
| const currentQueryHash = queryKeyHashFn(query.resolvedOptions.queryKey); | |
| const filterQueryHash = queryKeyHashFn(queryKey); | |
| const keyMatch = exact | |
| ? currentQueryHash === filterQueryHash | |
| : currentQueryHash.startsWith(filterQueryHash); | |
| if (!keyMatch) return false; | |
| } | |
| if (type === 'active' && !query.isActive) return false; | |
| if (type === 'inactive' && query.isActive) return false; | |
| if (stale === true && !query.state.isStale()) return false; | |
| if (stale === false && query.state.isStale()) return false; | |
| return true; | |
| }); | |
| const refetchPromises = queriesToRefetch.map((query) => | |
| query.fetch(undefined, throwOnError), | |
| ); | |
| if (cancelRefetch) { | |
| for (const query of queriesToRefetch) { | |
| query.cancel(); | |
| } | |
| } | |
| await Promise.all(refetchPromises); | |
| }; | |
| // #region cancelQueries | |
| const cancelQueries = async (filters?: { | |
| queryKey?: QueryKey; | |
| exact?: boolean; | |
| }): Promise<void> => { | |
| const { queryKey, exact = false } = filters || {}; | |
| const queriesToCancel = Array.from(cache.values()).filter((query) => { | |
| if (queryKey) { | |
| const currentQueryHash = queryKeyHashFn(query.resolvedOptions.queryKey); | |
| const filterQueryHash = queryKeyHashFn(queryKey); | |
| const keyMatch = exact | |
| ? currentQueryHash === filterQueryHash | |
| : currentQueryHash.startsWith(filterQueryHash); | |
| if (!keyMatch) return false; | |
| } | |
| return true; | |
| }); | |
| for (const query of queriesToCancel) { | |
| await query.cancel(); | |
| } | |
| }; | |
| // #region removeQueries | |
| const removeQueries = (filters?: { | |
| queryKey?: QueryKey; | |
| exact?: boolean; | |
| }): void => { | |
| const { queryKey, exact = false } = filters || {}; | |
| const queriesToRemove = Array.from(cache.entries()).filter( | |
| ([hash, query]) => { | |
| if (queryKey) { | |
| const currentQueryHash = queryKeyHashFn( | |
| query.resolvedOptions.queryKey, | |
| ); | |
| const filterQueryHash = queryKeyHashFn(queryKey); | |
| const keyMatch = exact | |
| ? currentQueryHash === filterQueryHash | |
| : currentQueryHash.startsWith(filterQueryHash); | |
| if (!keyMatch) return false; | |
| } | |
| return true; | |
| }, | |
| ); | |
| for (const [hash, query] of queriesToRemove) { | |
| query.destroy(); | |
| cache.delete(hash); | |
| } | |
| }; | |
| const resetQueries = async ( | |
| filters?: { | |
| queryKey?: QueryKey; | |
| exact?: boolean; | |
| }, | |
| options?: { | |
| throwOnError?: boolean; | |
| cancelRefetch?: boolean; | |
| }, | |
| ): Promise<void> => { | |
| const { queryKey, exact = false } = filters || {}; | |
| const { throwOnError = false, cancelRefetch = true } = options || {}; | |
| const queriesToReset = Array.from(cache.values()).filter((query) => { | |
| if (queryKey) { | |
| const currentQueryHash = queryKeyHashFn(query.resolvedOptions.queryKey); | |
| const filterQueryHash = queryKeyHashFn(queryKey); | |
| const keyMatch = exact | |
| ? currentQueryHash === filterQueryHash | |
| : currentQueryHash.startsWith(filterQueryHash); | |
| if (!keyMatch) return false; | |
| } | |
| return true; | |
| }); | |
| const resetPromises = queriesToReset.map(async (query) => { | |
| query.reset(); | |
| if (query.isActive) { | |
| try { | |
| await query.refetch({ throwOnError, cancelRefetch }); | |
| } catch (error) { | |
| if (throwOnError) { | |
| throw error; | |
| } | |
| } | |
| } | |
| }); | |
| await Promise.all(resetPromises); | |
| }; | |
| // #region ensureQueryData | |
| const ensureQueryData = async < | |
| TQueryFnData = unknown, | |
| TError = unknown, | |
| TData = TQueryFnData, | |
| TQueryKey extends QueryKey = QueryKey, | |
| >( | |
| options: Prettify< | |
| QueryOptions<TQueryFnData, TError, TData, TQueryKey> & { | |
| revalidateIfStale?: boolean; | |
| } | |
| >, | |
| ): Promise<TData> => { | |
| const { queryKey, revalidateIfStale = false, ...restOptions } = options; | |
| const queryHash = queryKeyHashFn(queryKey); | |
| const existingQuery = cache.get(queryHash); | |
| if (existingQuery) { | |
| const currentData = existingQuery.state.data(); | |
| if (currentData !== undefined) { | |
| if (revalidateIfStale && existingQuery.state.isStale()) { | |
| existingQuery.fetch().catch(() => {}); // Refetch in background | |
| } | |
| return currentData as TData; | |
| } | |
| } | |
| // If query doesn't exist or has no data, fetch it | |
| const query = createQuery(queryClient, { | |
| queryKey, | |
| ...restOptions, | |
| }); | |
| await query.fetch(); | |
| return query.state.data() as TData; | |
| }; | |
| // #region fetchQuery | |
| const fetchQuery = async < | |
| TQueryFnData = unknown, | |
| TError = unknown, | |
| TData = TQueryFnData, | |
| TQueryKey extends QueryKey = QueryKey, | |
| >( | |
| options: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>, | |
| ): Promise<TData> => { | |
| const { queryKey, queryFn, staleTime = 0, ...restOptions } = options; | |
| const queryHash = queryKeyHashFn(queryKey); | |
| const existingQuery = cache.get(queryHash); | |
| if (existingQuery) { | |
| const currentData = existingQuery.state.data(); | |
| if (currentData !== undefined && !existingQuery.state.isStale()) { | |
| return currentData as TData; | |
| } | |
| } | |
| const query = createQuery(queryClient, { | |
| queryKey, | |
| queryFn, | |
| ...restOptions, | |
| }); | |
| await query.fetch(); | |
| return query.state.data() as unknown as TData; | |
| }; | |
| // #region prefetchQuery | |
| const prefetchQuery = async < | |
| TQueryFnData = unknown, | |
| TError = unknown, | |
| TData = TQueryFnData, | |
| TQueryKey extends QueryKey = QueryKey, | |
| >( | |
| options: FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>, | |
| ): Promise<void> => { | |
| try { | |
| await fetchQuery(options); | |
| } catch (error) { | |
| // Silently catch any errors | |
| } | |
| }; | |
| const isFetching = (filters?: QueryFilters): number => { | |
| const queries = Array.from(cache.values()); | |
| const filteredQueries = filters | |
| ? queries.filter((query) => | |
| Object.entries(filters).every( | |
| ([key, value]) => query[key as keyof typeof query] === value, | |
| ), | |
| ) | |
| : queries; | |
| return filteredQueries.filter((query) => query.state.isFetching()).length; | |
| }; | |
| const isMutating = (filters?: MutationFilters): number => { | |
| const mutations = Array.from(mutationCache.values()); | |
| return mutations.filter((mutation) => { | |
| if (filters) { | |
| return Object.entries(filters).every( | |
| ([key, value]) => mutation[key as keyof typeof mutation] === value, | |
| ); | |
| } | |
| return mutation.state.status() === 'pending'; | |
| }).length; | |
| }; | |
| const getQueryCache = (): Map<string, Query> => { | |
| return cache; | |
| }; | |
| const getMutationCache = (): Map<string, MutationObject> => { | |
| return mutationCache; | |
| }; | |
| const clear = (): void => { | |
| cache.clear(); | |
| mutationCache.clear(); | |
| }; | |
| const queryClient: QueryClient = { | |
| setDefaultOptions, | |
| getDefaultOptions, | |
| setQueryDefaults, | |
| getQueryDefaults, | |
| setMutationDefaults, | |
| getMutationDefaults, | |
| isFetching, | |
| isMutating, | |
| fetchQuery, | |
| prefetchQuery, | |
| removeQueries, | |
| cancelQueries, | |
| refetchQueries, | |
| ensureQueryData, | |
| getQueryData, | |
| setQueryData, | |
| invalidateQueries, | |
| cache, | |
| mutationCache, | |
| getQueryCache, | |
| getMutationCache, | |
| clear, | |
| resetQueries, | |
| jobQueue, | |
| startQueueJob, | |
| finishQueueJob, | |
| }; | |
| return queryClient; | |
| }; | |
| // #region Hooks | |
| export function useQueryClient(queryClient?: QueryClient) { | |
| const client = queryClient ?? useContext(QueryClientContext); | |
| if (!client) { | |
| throw new Error('No QueryClient set, use QueryClientProvider to set one'); | |
| } | |
| return client; | |
| } | |
| type Prettify<T> = { | |
| [K in keyof T]: T[K]; | |
| } & {}; | |
| export function useQuery< | |
| TQueryFnData = unknown, | |
| TError = unknown, | |
| TData = TQueryFnData, | |
| TQueryKey extends QueryKey = QueryKey, | |
| TInitialData extends TQueryFnData | undefined = undefined, | |
| R = void, | |
| D = R extends void | |
| ? TInitialData extends TQueryFnData | |
| ? TInitialData | |
| : TQueryFnData | |
| : R, | |
| >( | |
| options: QueryOptions< | |
| TQueryFnData, | |
| TError, | |
| TData, | |
| TQueryKey, | |
| TInitialData, | |
| R | |
| >, | |
| ): ObservableReadonly< | |
| QueryStateReadonly<TInitialData extends undefined ? D | undefined : D> & { | |
| refetch: () => Promise<void>; | |
| cancel: () => void; | |
| } | |
| > { | |
| const queryClient = useQueryClient(options.queryClient); | |
| const query = useMemo(() => { | |
| return createQuery<TQueryFnData, TError, TData, TQueryKey, TInitialData, R>( | |
| queryClient, | |
| options, | |
| ); | |
| }); | |
| useEffect( | |
| () => { | |
| query().addInstance(); | |
| }, | |
| { sync: true }, | |
| ); | |
| return useMemo(() => { | |
| const state = Object.fromEntries( | |
| Object.entries(query().state).map(([key, value]) => [ | |
| key, | |
| key === 'meta' ? value : useReadonly(value as Observable<any>), | |
| ]), | |
| ) as QueryStateReadonly<D>; | |
| return { | |
| ...state, | |
| data: useMemo(() => { | |
| query().state.data(); | |
| if (state.isPending() && typeof options.initialData !== 'undefined') { | |
| return options.placeholderData as Awaited<D>; | |
| } | |
| if (options.select) { | |
| return options.select(state.data() as any) as any; | |
| } | |
| return query().state.data() as Awaited<D>; | |
| }), | |
| refetch: query().refetch, | |
| cancel: query().cancel, | |
| }; | |
| }); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { $$, useResolved } from 'voby'; | |
| import type { MutationKey } from './useMutation.ts'; | |
| import type { QueryKey, QueryOptions } from './useQuery.ts'; | |
| export function queryOptions<Q extends QueryOptions>(options: Q): Q { | |
| return options; | |
| } | |
| // #region Utils | |
| export const hashFn = (queryKey: QueryKey | MutationKey): string => { | |
| return JSON.stringify(useResolved($$(queryKey)), (_, val) => { | |
| return isPlainObject(val) | |
| ? Object.keys(val) | |
| .sort() | |
| .reduce((result, key) => { | |
| result[key] = val[key]; | |
| return result; | |
| }, {} as any) | |
| : val; | |
| }); | |
| }; | |
| // Copied from: https://github.com/jonschlinkert/is-plain-object | |
| export function isPlainObject(o: any): o is Object { | |
| 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]'; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { render, If, $, Observable, useEffect } from 'voby'; | |
| import { createQueryClient, QueryClientProvider, useQuery } from './index.ts'; | |
| interface Todo { | |
| userId: number; | |
| id: number; | |
| title: string; | |
| completed: boolean; | |
| } | |
| const fetchTodo = async (id: number): Promise<Todo> => { | |
| const url = `https://jsonplaceholder.typicode.com/todos/${id}`; | |
| console.log(`Fetching todo ${id}...`); | |
| const response = await fetch(url); | |
| if (!response.ok) { | |
| if (response.status === 404) { | |
| throw new Error(`Todo with ID ${id} not found.`); | |
| } | |
| throw new Error(`HTTP error! Status: ${response.status}`); | |
| } | |
| const data: Todo = await response.json(); | |
| return data; | |
| }; | |
| const SimpleTodo = (): JSX.Element => { | |
| const todoId: Observable<number> = $(1); | |
| const query = useQuery({ | |
| queryKey: ['todo', todoId], | |
| queryFn: () => fetchTodo(todoId()), | |
| retry: false, | |
| gcTime: 100, | |
| staleTime: 100, | |
| }); | |
| useEffect(() => { | |
| console.log("Query data:", query().data()) | |
| }) | |
| const handleRefetch = () => { | |
| query().refetch(); | |
| }; | |
| const handleNextTodo = () => { | |
| todoId(id => id + 1); | |
| }; | |
| return ( | |
| <div class="bg-white max-w-lg m-auto mt-20 p-4 rounded-xl"> | |
| <h1>Simple Todo Fetch</h1> | |
| <p>Current Todo ID: {todoId}</p> | |
| <div style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem' }}> | |
| <button class="bg-blue-600 rounded px-3 text-white" onClick={handleRefetch} disabled={() => query().isPending()}> | |
| {() => query().isRefetching() ? 'Refreshing...' : 'Refetch Current'} | |
| </button> | |
| <button class="bg-blue-600 rounded px-3 text-white" onClick={handleNextTodo} disabled={() => query().isPending()}> | |
| {() => query().isPending() ? 'Loading Next...' : 'Next Todo'} | |
| </button> | |
| <button class="bg-blue-600 rounded px-3 text-white" onClick={() => todoId(id => id - 1)} disabled={() => query().isPending()}> | |
| {() => query().isPending() ? 'Loading Prev...' : 'Previous Todo'} | |
| </button> | |
| </div> | |
| <div style={{ minHeight: '150px', position: 'relative' }}> | |
| <If when={() => query().isPending()}> | |
| <p style={{ color: '#888' }}>Loading...</p> | |
| </If> | |
| <If when={() => query().isError()}> | |
| <p style={{ color: 'red' }}>Error fetching todo: {() => query().error()?.message ?? 'Unknown error'}</p> | |
| </If> | |
| <If when={() => query().isSuccess() && query().data()}> | |
| {(data) => ( | |
| <div style={{ opacity: query().isFetching() ? 0.6 : 1, transition: 'opacity 0.2s ease-in-out' }}> | |
| <h2>Todo Item #{() => data().id}</h2> | |
| <p><strong>Todo title:</strong> {() => data().title}</p> | |
| <p><strong>Todo status:</strong> {() => data().completed ? 'Completed' : 'Pending'}</p> | |
| </div> | |
| )} | |
| </If> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| const queryClient = createQueryClient(); | |
| const App = () => ( | |
| <QueryClientProvider value={queryClient}> | |
| <SimpleTodo /> | |
| <SimpleTodo /> | |
| </QueryClientProvider> | |
| ); | |
| render(<App />, document.getElementById('app')); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment