diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index a7763cf648..b733f919a0 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -34,6 +34,7 @@ export { noop, partialMatchKey, replaceEqualDeep, + resolveEnabled, shouldThrowError, skipToken, } from './utils' diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 463407a073..64c983a42c 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -584,7 +584,7 @@ export class QueryObserver< isRefetchError: isError && hasData, isStale: isStale(query, options), refetch: this.refetch, - promise: this.#currentThenable, + promise: tagThenable(this.#currentThenable, query.queryHash), isEnabled: resolveEnabled(options.enabled, query) !== false, } @@ -608,7 +608,7 @@ export class QueryObserver< const pending = (this.#currentThenable = nextResult.promise = - pendingThenable()) + tagThenable(pendingThenable(), query.queryHash)) finalizeThenableIfPossible(pending) } @@ -628,7 +628,11 @@ export class QueryObserver< } break case 'rejected': - if (!isErrorWithoutData || nextResult.error !== prevThenable.reason) { + if ( + !isErrorWithoutData || + nextResult.error !== prevThenable.reason || + nextResult.fetchStatus === 'fetching' + ) { recreateThenable() } break @@ -826,3 +830,22 @@ function shouldAssignObserverCurrentProperties< // basically, just keep previous properties if nothing changed return false } + +function tagThenable>( + thenable: TThenable, + queryHash: string, +): TThenable { + if (!Object.prototype.hasOwnProperty.call(thenable, 'queryHash')) { + Object.defineProperty(thenable, 'queryHash', { + value: queryHash, + enumerable: false, + configurable: true, + }) + } + return thenable +} + +/** + * @internal + */ +export type PromiseWithHash = Promise & { queryHash?: string } diff --git a/packages/react-query/src/QueryErrorResetBoundary.tsx b/packages/react-query/src/QueryErrorResetBoundary.tsx index 910215bcb6..60850407bb 100644 --- a/packages/react-query/src/QueryErrorResetBoundary.tsx +++ b/packages/react-query/src/QueryErrorResetBoundary.tsx @@ -1,6 +1,8 @@ 'use client' import * as React from 'react' +import { useQueryClient } from './QueryClientProvider' + // CONTEXT export type QueryErrorResetFunction = () => void export type QueryErrorIsResetFunction = () => boolean @@ -10,6 +12,7 @@ export interface QueryErrorResetBoundaryValue { clearReset: QueryErrorClearResetFunction isReset: QueryErrorIsResetFunction reset: QueryErrorResetFunction + register: (queryHash: string) => void } function createValue(): QueryErrorResetBoundaryValue { @@ -24,6 +27,7 @@ function createValue(): QueryErrorResetBoundaryValue { isReset: () => { return isReset }, + register: () => {}, } } @@ -47,10 +51,59 @@ export interface QueryErrorResetBoundaryProps { export const QueryErrorResetBoundary = ({ children, }: QueryErrorResetBoundaryProps) => { - const [value] = React.useState(() => createValue()) + const client = useQueryClient() + const registeredQueries = React.useRef(new Set()) + const [value] = React.useState(() => { + const boundary = createValue() + return { + ...boundary, + reset: () => { + boundary.reset() + const queryHashes = new Set(registeredQueries.current) + registeredQueries.current.clear() + + void client.refetchQueries({ + predicate: (query) => + queryHashes.has(query.queryHash) && query.state.status === 'error', + type: 'active', + }) + }, + register: (queryHash: string) => { + registeredQueries.current.add(queryHash) + }, + } + }) return ( {typeof children === 'function' ? children(value) : children} ) } + +/** + * @internal + */ +export function getQueryHash(query: any): string | undefined { + if (typeof query === 'object' && query !== null) { + if ('queryHash' in query) { + return query.queryHash + } + if ( + 'promise' in query && + query.promise && + typeof query.promise === 'object' && + 'queryHash' in query.promise + ) { + return query.promise.queryHash + } + } + return undefined +} + +export function useTrackQueryHash(query: any) { + const { register } = useQueryErrorResetBoundary() + const hash = getQueryHash(query) + if (hash) { + register(hash) + } +} diff --git a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx index c02adeeece..2790390737 100644 --- a/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx +++ b/packages/react-query/src/__tests__/QueryResetErrorBoundary.test.tsx @@ -11,6 +11,7 @@ import { useQuery, useSuspenseQueries, useSuspenseQuery, + useTrackQueryHash, } from '..' import { renderWithClient } from './utils' @@ -863,4 +864,239 @@ describe('QueryErrorResetBoundary', () => { consoleMock.mockRestore() }) }) + + describe('Scoped Registry', () => { + it('should isolate resets between different boundaries', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key1 = queryKey() + const key2 = queryKey() + let count1 = 0 + let count2 = 0 + + function Comp1() { + useQuery({ + queryKey: key1, + queryFn: async () => { + await sleep(10) + count1++ + throw new Error('fail1') + }, + retry: false, + throwOnError: true, + }) + return null + } + + function Comp2() { + useQuery({ + queryKey: key2, + queryFn: async () => { + await sleep(10) + count2++ + throw new Error('fail2') + }, + retry: false, + throwOnError: true, + }) + return null + } + + const rendered = renderWithClient( + queryClient, + <> + + {({ reset }) => ( + ( +
+ +
+ )} + > + + + +
+ )} +
+ + {({ reset }) => ( + ( +
+ +
+ )} + > + + + +
+ )} +
+ , + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('reset1')).toBeInTheDocument() + expect(rendered.getByText('reset2')).toBeInTheDocument() + expect(count1).toBe(1) + expect(count2).toBe(1) + + fireEvent.click(rendered.getByText('reset1')) + + await vi.advanceTimersByTimeAsync(11) + expect(count1).toBe(2) + expect(count2).toBe(1) + + consoleMock.mockRestore() + }) + + it('should clear registry after reset', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + let count = 0 + + function Comp() { + useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + throw new Error('fail') + }, + retry: false, + throwOnError: true, + }) + return null + } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( +
+ +
+ )} + > + + + +
+ )} +
, + ) + + await vi.advanceTimersByTimeAsync(11) + expect(rendered.getByText('reset')).toBeInTheDocument() + expect(count).toBe(1) + + fireEvent.click(rendered.getByText('reset')) + await vi.advanceTimersByTimeAsync(11) + expect(count).toBe(2) + + consoleMock.mockRestore() + }) + + it('should handle StrictMode double registration gracefully', async () => { + const key = queryKey() + let count = 0 + + function Comp() { + useQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + return 'ok' + }, + }) + return null + } + + renderWithClient( + queryClient, + + + + + , + ) + + await vi.advanceTimersByTimeAsync(11) + expect(count).toBeGreaterThanOrEqual(1) + }) + + it('should support tracking queries outside the boundary via useTrackQueryHash', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + let count = 0 + + function Child() { + const { data } = useSuspenseQuery({ + queryKey: key, + queryFn: async () => { + await sleep(10) + count++ + if (count === 1) { + throw new Error('fail') + } + return 'ok' + }, + retry: false, + }) + return
{data}
+ } + + function TrackedChild() { + const hash = queryClient + .getQueryCache() + .build(queryClient, { queryKey: key }).queryHash + useTrackQueryHash({ queryHash: hash }) + return null + } + + const rendered = renderWithClient( + queryClient, + + {({ reset }) => ( + ( + + )} + > + + + + + + )} + , + ) + + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('retry')).toBeInTheDocument() + expect(count).toBe(1) + + fireEvent.click(rendered.getByText('retry')) + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(count).toBe(2) + expect(rendered.getByText('ok')).toBeInTheDocument() + + consoleMock.mockRestore() + }) + }) }) diff --git a/packages/react-query/src/__tests__/useQuery.promise.test.tsx b/packages/react-query/src/__tests__/useQuery.promise.test.tsx index 5e1d892df0..7989cbfe0a 100644 --- a/packages/react-query/src/__tests__/useQuery.promise.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.promise.test.tsx @@ -14,6 +14,7 @@ import { keepPreviousData, useInfiniteQuery, useQuery, + useTrackQueryHash, } from '..' import { QueryCache } from '../index' @@ -1504,4 +1505,80 @@ describe('useQuery().promise', { timeout: 10_000 }, () => { expect(rendered.queryByText('error boundary')).toBeNull() }) + + it('should retry when QERB triggers reset, even if useQuery is outside', async () => { + const key = queryKey() + const renderStream = createRenderStream({ snapshotDOM: true }) + + let queryCount = 0 + function Child(props: { promise: Promise }) { + useTrackQueryHash(props.promise) + const data = React.use(props.promise) + return <>{data} + } + + function Page() { + const query = useQuery({ + queryKey: key, + queryFn: async () => { + await vi.advanceTimersByTimeAsync(1) + queryCount++ + if (queryCount === 1) { + throw new Error('Error test') + } + return 'data' + }, + retry: false, + }) + + return ( + + {({ reset }) => ( + ( +
+
error boundary
+ +
+ )} + > + loading..}> + + +
+ )} +
+ ) + } + + const rendered = await renderStream.render( + + + , + ) + + { + const { withinDOM } = await renderStream.takeRender() + expect(withinDOM().getByText('loading..')).toBeInTheDocument() + } + + { + const { withinDOM } = await renderStream.takeRender() + expect(withinDOM().getByText('error boundary')).toBeInTheDocument() + } + + rendered.getByText('retry').click() + + await waitFor(() => { + expect(rendered.getByText('loading..')).toBeInTheDocument() + }) + + await waitFor(() => { + expect(rendered.getByText('data')).toBeInTheDocument() + }) + + expect(queryCount).toBe(2) + }) }) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index 36ea8da7af..a3a9121196 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -45,6 +45,8 @@ export type { QueryErrorResetFunction, } from './QueryErrorResetBoundary' export { + useTrackQueryHash, + getQueryHash, QueryErrorResetBoundary, useQueryErrorResetBoundary, } from './QueryErrorResetBoundary' diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 2a151fe113..9d39cd2b67 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -83,6 +83,13 @@ export function useBaseQuery< ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary, query) useClearResetErrorBoundary(errorResetBoundary) + if ( + defaultedOptions.experimental_prefetchInRender || + defaultedOptions.suspense + ) { + errorResetBoundary.register(defaultedOptions.queryHash) + } + // this needs to be invoked before creating the Observer because that can create a cache entry const isNewCacheEntry = !client .getQueryCache()