diff --git a/.changeset/hydrated-rejection-retry.md b/.changeset/hydrated-rejection-retry.md new file mode 100644 index 00000000000..6d4d225fe86 --- /dev/null +++ b/.changeset/hydrated-rejection-retry.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +When a query hydrated while pending rejects, it now retries with the `retry` and `retryDelay` an observer set, if one mounted before the promise settled. Options the observer doesn't set itself, including client defaults, keep the values the query was hydrated with. Once an observer has mounted, a hydrated infinite query's retries run its `queryFn` from the first page, instead of replaying the rejected promise. diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index ee037a11afe..615c3ce52c8 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1,10 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createKey, sleep } from '@tanstack/query-test-utils' +import { + clearOnDispose, + createKey, + fakeTimers, + sleep, + subscribe, +} from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import { QueryCache } from '../queryCache' import { dehydrate, hydrate } from '../hydration' import { dehydrateQuery } from '../index' import { MutationCache } from '../mutationCache' +import { InfiniteQueryObserver } from '../infiniteQueryObserver' +import { QueryObserver } from '../queryObserver' import { noop } from '../utils' import { executeMutation, mockOnlineManagerIsOnline } from './utils' import type { MutationFunction, MutationFunctionContext } from '../index' @@ -1096,6 +1104,387 @@ describe('dehydration and rehydration', () => { }) }) + describe('a hydrated promise that rejects', () => { + function startRejectingQuery(key: Array) { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: () => true, + shouldRedactErrors: () => false, + }, + }, + }) + void serverClient + .query({ + queryKey: key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('server error'))), + retry: false, + }) + .catch(noop) + return clearOnDispose(serverClient) + } + + function startRejectingInfiniteQuery(key: Array) { + const serverClient = new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: () => true, + shouldRedactErrors: () => false, + }, + }, + }) + void serverClient + .infiniteQuery({ + queryKey: key, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('server error'))), + initialPageParam: 0, + getNextPageParam: () => undefined, + retry: false, + }) + .catch(noop) + return clearOnDispose(serverClient) + } + + it('should retry with the retry options of an observer that mounted before it rejected', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose(new QueryClient()) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }), + noop, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).not.toHaveBeenCalled() + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchFailureCount: 1, + }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryFn).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'success', + data: 'client data', + fetchStatus: 'idle', + }) + }) + + it("should resolve a fetch that joins the hydrated promise with the data of the observer's retry", async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose(new QueryClient()) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }), + noop, + ) + const joined = queryClient.query({ queryKey: key, queryFn }) + + await vi.advanceTimersByTimeAsync(25) + + await expect(joined).resolves.toBe('client data') + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should not retry when an observer that mounted before it rejected sets retry to false', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose(new QueryClient()) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { + queryKey: key, + queryFn, + retry: false, + }), + noop, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + error: new Error('server error'), + fetchStatus: 'idle', + }) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should keep the hydration retry options that an observer does not set', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }), + ) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { queryKey: key, queryFn }), + noop, + ) + + await vi.advanceTimersByTimeAsync(15) + expect(queryFn).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'success', + data: 'client data', + }) + }) + + it("should keep the hydration retry options over the client's query defaults", async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + queries: { retry: 2, retryDelay: 5 }, + hydrate: { queries: { retry: 0 } }, + }, + }), + ) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { queryKey: key, queryFn }), + noop, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + fetchFailureCount: 1, + }) + expect(queryFn).not.toHaveBeenCalled() + }) + + it('should keep the hydration retry options over setQueryDefaults', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { hydrate: { queries: { retry: 0 } } }, + }), + ) + queryClient.setQueryDefaults(key, { retry: 2, retryDelay: 5 }) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver(queryClient, { queryKey: key, queryFn }), + noop, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + fetchFailureCount: 1, + }) + expect(queryFn).not.toHaveBeenCalled() + }) + + it("should retry with the retry options an observer sets itself over the client's query defaults", async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + queries: { retry: 0 }, + hydrate: { queries: { retry: 0 } }, + }, + }), + ) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn<() => Promise>(() => + sleep(10).then(() => 'client data'), + ) + using _subscription = subscribe( + new QueryObserver( + queryClient, + queryClient.defaultQueryOptions({ + queryKey: key, + queryFn, + retry: 1, + retryDelay: 5, + }), + ), + noop, + ) + + await vi.advanceTimersByTimeAsync(15) + expect(queryFn).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'success', + data: 'client data', + }) + }) + + it('should retry with the hydration options when no observer has mounted', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }), + ) + hydrate(queryClient, dehydrated) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchFailureCount: 1, + }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + error: new Error('server error'), + fetchFailureCount: 2, + fetchStatus: 'idle', + }) + }) + + it('should retry a hydrated infinite query with the queryFn of an observer that mounted before it rejected', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingInfiniteQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { hydrate: { queries: { retry: 0 } } }, + }), + ) + hydrate(queryClient, dehydrated) + const queryFn = vi.fn< + (context: { pageParam: number }) => Promise + >(({ pageParam }) => sleep(10).then(() => `page ${pageParam}`)) + using _subscription = subscribe( + new InfiniteQueryObserver(queryClient, { + queryKey: key, + queryFn, + initialPageParam: 0, + getNextPageParam: () => undefined, + retry: 1, + retryDelay: 5, + }), + noop, + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryFn).not.toHaveBeenCalled() + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchFailureCount: 1, + }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryFn).toHaveBeenCalledTimes(1) + expect(queryFn).toHaveBeenCalledWith( + expect.objectContaining({ pageParam: 0 }), + ) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'success', + data: { pages: ['page 0'], pageParams: [0] }, + fetchStatus: 'idle', + }) + }) + + it('should retry a hydrated infinite query with the hydration options when no observer has mounted', async () => { + using _timers = fakeTimers() + const key = createKey() + using serverClient = startRejectingInfiniteQuery(key) + const dehydrated = dehydrate(serverClient) + + using queryClient = clearOnDispose( + new QueryClient({ + defaultOptions: { + hydrate: { queries: { retry: 1, retryDelay: 5 } }, + }, + }), + ) + hydrate(queryClient, dehydrated) + + await vi.advanceTimersByTimeAsync(10) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'pending', + fetchFailureCount: 1, + }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryClient.getQueryState(key)).toMatchObject({ + status: 'error', + error: new Error('server error'), + fetchFailureCount: 2, + fetchStatus: 'idle', + }) + }) + }) + it('should transform promise result', async () => { const key = createKey() const queryClient = new QueryClient({ diff --git a/packages/query-core/src/__tests__/query.test.tsx b/packages/query-core/src/__tests__/query.test.tsx index 4e1fc8dccdf..f5851ff98bc 100644 --- a/packages/query-core/src/__tests__/query.test.tsx +++ b/packages/query-core/src/__tests__/query.test.tsx @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + clearOnDispose, createKey, + fakeTimers, mockVisibilityState, sleep, } from '@tanstack/query-test-utils' @@ -114,6 +116,34 @@ describe('query', () => { expect(result).toBe('data3') }) + it('should keep the retry options a fetch started with when the options change before a retry', async () => { + using _timers = fakeTimers() + using client = clearOnDispose(new QueryClient()) + const key = createKey() + const queryFn = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('error')) + .mockResolvedValue('data') + + void client + .query({ queryKey: key, queryFn, retry: 1, retryDelay: 50 }) + .catch(noop) + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(1) + + client + .getQueryCache() + .find({ queryKey: key })! + .setOptions({ queryKey: key, queryFn, retry: 1, retryDelay: 5 }) + + await vi.advanceTimersByTimeAsync(5) + expect(queryFn).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(45) + expect(queryFn).toHaveBeenCalledTimes(2) + expect(client.getQueryData(key)).toBe('data') + }) + it('should continue retry after reconnect and resolve all promises', async () => { const key = createKey() diff --git a/packages/query-core/src/infiniteQueryBehavior.ts b/packages/query-core/src/infiniteQueryBehavior.ts index 20bc95ff225..944793de156 100644 --- a/packages/query-core/src/infiniteQueryBehavior.ts +++ b/packages/query-core/src/infiniteQueryBehavior.ts @@ -28,7 +28,6 @@ export function infiniteQueryBehavior( ): QueryBehavior> { return { onFetch: (context, query) => { - const options = context.options as InfiniteQueryPageParamsOptions const direction = context.fetchOptions?.meta?.fetchMore?.direction const oldPages = context.state.data?.pages || [] const oldPageParams = context.state.data?.pageParams || [] @@ -36,6 +35,12 @@ export function infiniteQueryBehavior( let currentPage = 0 const fetchFn = async () => { + // A fetch that resumes a hydrated promise only calls this to retry, and + // an observer may have set the query's options since the fetch started + const queryOptions = ( + context.fetchOptions?.initialPromise ? query.options : context.options + ) as typeof context.options + const options = queryOptions as InfiniteQueryPageParamsOptions let cancelled = false const signalContext: FetchSignalContext = context const addSignalProperty = (object: object) => { @@ -47,7 +52,7 @@ export function infiniteQueryBehavior( ) } - const queryFn = ensureQueryFn(context.options, context.fetchOptions) + const queryFn = ensureQueryFn(queryOptions, context.fetchOptions) // Create function to fetch a page const fetchPage = async ( @@ -72,7 +77,7 @@ export function infiniteQueryBehavior( queryKey: context.queryKey, pageParam: param, direction: previous ? 'backward' : 'forward', - meta: context.options.meta, + meta: queryOptions.meta, } addSignalProperty(queryFnContext) return queryFnContext as QueryFunctionContext @@ -82,7 +87,7 @@ export function infiniteQueryBehavior( const page = await queryFn(queryFnContext) - const { maxPages } = context.options + const { maxPages } = queryOptions const addTo = previous ? addToStart : addToEnd return { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index af9a81baebd..544da6d1498 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -1,6 +1,7 @@ import { ensureQueryFn, fetchSignal, + getExplicitRetryOptions, noop, replaceData, resolveQueryValue, @@ -829,6 +830,23 @@ export class Query< : this.options.behavior behavior?.onFetch(context, this as unknown as Query) + // A hydrated promise starts fetching before any observer mounts. At each + // failure, retry options an observer set itself win over those from + // hydration, and client defaults don't count as set + const getRetryOption = ( + key: TKey, + ): QueryOptions[TKey] => { + if (fetchOptions?.initialPromise) { + for (const observer of this.observers) { + const value = getExplicitRetryOptions(observer.options)?.[key] + if (value !== undefined) { + return value + } + } + } + return context.options[key] + } + // Store state in case the current fetch needs to be reverted this.#revertState = this.state @@ -864,8 +882,12 @@ export class Query< onContinue: () => { this.#dispatch({ type: 'continue' }) }, - retry: context.options.retry, - retryDelay: context.options.retryDelay, + get retry() { + return getRetryOption('retry') + }, + get retryDelay() { + return getRetryOption('retryDelay') + }, networkMode: context.options.networkMode, canRun: () => true, })) diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index faa0b130ab2..390494bc0aa 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -5,6 +5,7 @@ import { noop, partialMatchKey, resolveQueryValue, + setExplicitRetryOptions, skipToken, } from './utils' import { QueryCache } from './queryCache' @@ -1102,6 +1103,7 @@ export class QueryClient { ...options, _defaulted: true, } + setExplicitRetryOptions(defaultedOptions, options) if (!defaultedOptions.queryHash) { defaultedOptions.queryHash = hashQueryKeyByOptions( diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 64e6a664ab5..f9252df56e0 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -629,6 +629,43 @@ export const skipToken = Symbol() */ export type SkipToken = typeof skipToken +type RetryOptions = Pick< + QueryOptions, + 'retry' | 'retryDelay' +> + +const explicitRetryOptions = new WeakMap() + +/** + * Remembers the `retry` and `retryDelay` a caller set itself, before client defaults filled them in. + * @internal + * @param defaultedOptions - The options `defaultQueryOptions` returns. + * @param options - The options the caller passed in. + */ +export function setExplicitRetryOptions( + defaultedOptions: object, + options: RetryOptions, +): void { + if (options.retry !== undefined || options.retryDelay !== undefined) { + explicitRetryOptions.set(defaultedOptions, { + retry: options.retry, + retryDelay: options.retryDelay, + }) + } +} + +/** + * Returns the `retry` and `retryDelay` a caller set itself on options from `defaultQueryOptions`. + * @internal + * @param defaultedOptions - The options `defaultQueryOptions` returned. + * @returns The options the caller set, or `undefined` if it set neither. + */ +export function getExplicitRetryOptions( + defaultedOptions: object, +): RetryOptions | undefined { + return explicitRetryOptions.get(defaultedOptions) +} + /** * Returns the function to call for a fetch. If there is no `queryFn` but the fetch was started from * an `initialPromise`, that promise is returned instead. If there is no usable `queryFn` (missing or diff --git a/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts new file mode 100644 index 00000000000..c50b7ed1d89 --- /dev/null +++ b/packages/query-test-utils/src/__tests__/clearOnDispose.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest' +import { clearOnDispose } from '../clearOnDispose' + +describe('clearOnDispose', () => { + it('should clear the client on dispose', () => { + const clear = vi.fn<() => void>() + { + using client = clearOnDispose({ clear }) + expect(client.clear).toBe(clear) + expect(clear).not.toHaveBeenCalled() + } + expect(clear).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/query-test-utils/src/__tests__/fakeTimers.test.ts b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts new file mode 100644 index 00000000000..0bbf2f6090b --- /dev/null +++ b/packages/query-test-utils/src/__tests__/fakeTimers.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it, vi } from 'vitest' +import { fakeTimers } from '../fakeTimers' + +describe('fakeTimers', () => { + it('should switch back to real timers on dispose', () => { + { + using _timers = fakeTimers() + expect(vi.isFakeTimers()).toBe(true) + } + expect(vi.isFakeTimers()).toBe(false) + }) +}) diff --git a/packages/query-test-utils/src/__tests__/subscribe.test.ts b/packages/query-test-utils/src/__tests__/subscribe.test.ts new file mode 100644 index 00000000000..ffafc471705 --- /dev/null +++ b/packages/query-test-utils/src/__tests__/subscribe.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import { subscribe } from '../subscribe' + +describe('subscribe', () => { + it('should unsubscribe the listener on dispose', () => { + const listeners = new Set<() => void>() + const subscribable = { + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + } + const listener = vi.fn<() => void>() + { + using _subscription = subscribe(subscribable, listener) + expect(listeners).toStrictEqual(new Set([listener])) + } + expect(listeners.size).toBe(0) + }) +}) diff --git a/packages/query-test-utils/src/clearOnDispose.ts b/packages/query-test-utils/src/clearOnDispose.ts new file mode 100644 index 00000000000..9bca266bff4 --- /dev/null +++ b/packages/query-test-utils/src/clearOnDispose.ts @@ -0,0 +1,19 @@ +/** + * Clears a client, like a `QueryClient`, when a test's `using` declaration disposes it. + * @param client - The client to clear. + * @returns The same client, disposable. + * @example + * ```ts + * it('fetches', async () => { + * using queryClient = clearOnDispose(new QueryClient()) + * }) + * ``` + */ +export const clearOnDispose = void }>( + client: TClient, +): TClient & Disposable => + Object.assign(client, { + [Symbol.dispose]: () => { + client.clear() + }, + }) diff --git a/packages/query-test-utils/src/fakeTimers.ts b/packages/query-test-utils/src/fakeTimers.ts new file mode 100644 index 00000000000..5988b14d8b7 --- /dev/null +++ b/packages/query-test-utils/src/fakeTimers.ts @@ -0,0 +1,21 @@ +import { vi } from 'vitest' + +/** + * Switches to fake timers until a test's `using` declaration disposes the result. + * @returns A disposable that switches back to real timers. + * @example + * ```ts + * it('waits', async () => { + * using _timers = fakeTimers() + * await vi.advanceTimersByTimeAsync(10) + * }) + * ``` + */ +export const fakeTimers = (): Disposable => { + vi.useFakeTimers() + return { + [Symbol.dispose]: () => { + vi.useRealTimers() + }, + } +} diff --git a/packages/query-test-utils/src/index.ts b/packages/query-test-utils/src/index.ts index 6366858002e..60f71375191 100644 --- a/packages/query-test-utils/src/index.ts +++ b/packages/query-test-utils/src/index.ts @@ -1,3 +1,6 @@ export { sleep } from './sleep' export { createKey } from './createKey' export { mockVisibilityState } from './mockVisibilityState' +export { fakeTimers } from './fakeTimers' +export { clearOnDispose } from './clearOnDispose' +export { subscribe } from './subscribe' diff --git a/packages/query-test-utils/src/subscribe.ts b/packages/query-test-utils/src/subscribe.ts new file mode 100644 index 00000000000..63057dfeebe --- /dev/null +++ b/packages/query-test-utils/src/subscribe.ts @@ -0,0 +1,23 @@ +/** + * Subscribes a listener until a test's `using` declaration disposes the result. + * @param subscribable - What to subscribe to, like a `QueryObserver` or a `QueryCache`. + * @param listener - The listener to subscribe. + * @returns A disposable that unsubscribes the listener. + * @example + * ```ts + * it('fetches', () => { + * using _subscription = subscribe(new QueryObserver(queryClient, options), noop) + * }) + * ``` + */ +export const subscribe = ( + subscribable: { subscribe: (listener: TListener) => () => void }, + listener: NoInfer, +): Disposable => { + const unsubscribe = subscribable.subscribe(listener) + return { + [Symbol.dispose]: () => { + unsubscribe() + }, + } +}