diff --git a/.changeset/preact-retry-on-mount-throw-on-error.md b/.changeset/preact-retry-on-mount-throw-on-error.md new file mode 100644 index 00000000000..21a7fd14a04 --- /dev/null +++ b/.changeset/preact-retry-on-mount-throw-on-error.md @@ -0,0 +1,5 @@ +--- +'@tanstack/preact-query': patch +--- + +Evaluate a function-form `throwOnError` against the actual query error before disabling `retryOnMount` (port of #9338), so errored queries whose `throwOnError` returns `false` are retried on mount again. diff --git a/packages/preact-query/src/__tests__/useQuery.test.tsx b/packages/preact-query/src/__tests__/useQuery.test.tsx index 3a36725ff09..db1971ec4aa 100644 --- a/packages/preact-query/src/__tests__/useQuery.test.tsx +++ b/packages/preact-query/src/__tests__/useQuery.test.tsx @@ -6740,4 +6740,171 @@ describe('useQuery', () => { expect(rendered.getByTestId('data')).toHaveTextContent('undefined') expect(queryFn).toHaveBeenCalledTimes(0) }) + it('should retry on mount when throwOnError returns false', async () => { + const key = queryKey() + let fetchCount = 0 + const queryFn = vi.fn().mockImplementation(() => { + fetchCount++ + console.log(`Fetching... (attempt ${fetchCount})`) + return Promise.reject(new Error('Simulated 500 error')) + }) + + function Component() { + const { status, error } = useQuery({ + queryKey: key, + queryFn, + throwOnError: () => false, + retryOnMount: () => true, + staleTime: Infinity, + retry: false, + }) + + return ( +
+
{status}
+ {error &&
{error.message}
} +
+ ) + } + + const rendered1 = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + expect(rendered1.getByTestId('status')).toHaveTextContent('error') + expect(rendered1.getByTestId('error')).toHaveTextContent( + 'Simulated 500 error', + ) + expect(fetchCount).toBe(1) + rendered1.unmount() + + const initialFetchCount = fetchCount + + const rendered2 = renderWithClient(queryClient, ) + await vi.advanceTimersByTimeAsync(0) + expect(rendered2.getByTestId('status')).toHaveTextContent('error') + + expect(fetchCount).toBe(initialFetchCount + 1) + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + it('should not retry on mount when throwOnError function returns true', async () => { + const key = queryKey() + let fetchCount = 0 + const queryFn = vi.fn().mockImplementation(() => { + fetchCount++ + console.log(`Fetching... (attempt ${fetchCount})`) + return Promise.reject(new Error('Simulated 500 error')) + }) + + function Component() { + const { status, error } = useQuery({ + queryKey: key, + queryFn, + throwOnError: () => true, + retryOnMount: () => true, + staleTime: Infinity, + retry: false, + }) + + return ( +
+
{status}
+ {error &&
{error.message}
} +
+ ) + } + + const rendered1 = renderWithClient( + queryClient, + ( +
+
error
+
{error?.message}
+
+ )} + > + +
, + ) + await vi.advanceTimersByTimeAsync(0) + expect(rendered1.getByTestId('status')).toHaveTextContent('error') + expect(rendered1.getByTestId('error')).toHaveTextContent( + 'Simulated 500 error', + ) + expect(fetchCount).toBe(1) + + rendered1.unmount() + + const initialFetchCount = fetchCount + + const rendered2 = renderWithClient( + queryClient, + ( +
+
error
+
{error?.message}
+
+ )} + > + +
, + ) + await vi.advanceTimersByTimeAsync(0) + expect(rendered2.getByTestId('status')).toHaveTextContent('error') + + // Should not retry because throwOnError returns true + expect(fetchCount).toBe(initialFetchCount) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it('should handle throwOnError function based on actual error state', async () => { + const key = queryKey() + let fetchCount = 0 + const queryFn = vi.fn().mockImplementation(() => { + fetchCount++ + console.log(`Fetching... (attempt ${fetchCount})`) + return Promise.reject(new Error('Simulated 500 error')) + }) + + function Component() { + const { status, error } = useQuery({ + queryKey: key, + queryFn, + throwOnError: (error) => error.message.includes('404'), + retryOnMount: true, + staleTime: Infinity, + retry: false, + }) + + return ( +
+
{status}
+ {error &&
{error.message}
} +
+ ) + } + + const rendered1 = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered1.getByTestId('status')).toHaveTextContent('error') + expect(rendered1.getByTestId('error')).toHaveTextContent( + 'Simulated 500 error', + ) + expect(fetchCount).toBe(1) + + rendered1.unmount() + + const initialFetchCount = fetchCount + + const rendered2 = renderWithClient(queryClient, ) + + await vi.advanceTimersByTimeAsync(0) + expect(rendered2.getByTestId('status')).toHaveTextContent('error') + + // Should retry because throwOnError returns false (500 error doesn't include '404') + expect(fetchCount).toBe(initialFetchCount + 1) + expect(queryFn).toHaveBeenCalledTimes(2) + }) }) diff --git a/packages/preact-query/src/errorBoundaryUtils.ts b/packages/preact-query/src/errorBoundaryUtils.ts index 0b3d561fea6..86fbccd29f1 100644 --- a/packages/preact-query/src/errorBoundaryUtils.ts +++ b/packages/preact-query/src/errorBoundaryUtils.ts @@ -25,11 +25,17 @@ export const ensurePreventErrorBoundaryRetry = < TQueryKey >, errorResetBoundary: QueryErrorResetBoundaryValue, + query: Query | undefined, ) => { + const throwOnError = + query?.state.error && typeof options.throwOnError === 'function' + ? shouldThrowError(options.throwOnError, [query.state.error, query]) + : options.throwOnError + if ( options.suspense || - options.throwOnError || - options.experimental_prefetchInRender + options.experimental_prefetchInRender || + throwOnError ) { // Prevent retrying failed query if the error boundary has not been reset yet if (!errorResetBoundary.isReset()) { diff --git a/packages/preact-query/src/useBaseQuery.ts b/packages/preact-query/src/useBaseQuery.ts index 05ce46e7f6e..ec820b7586b 100644 --- a/packages/preact-query/src/useBaseQuery.ts +++ b/packages/preact-query/src/useBaseQuery.ts @@ -58,6 +58,15 @@ export function useBaseQuery< defaultedOptions, ) + const query = client + .getQueryCache() + .get< + TQueryFnData, + TError, + TQueryData, + TQueryKey + >(defaultedOptions.queryHash) + if (process.env.NODE_ENV !== 'production') { if (!defaultedOptions.queryFn) { console.error( @@ -72,7 +81,7 @@ export function useBaseQuery< : 'optimistic' ensureSuspenseTimers(defaultedOptions) - ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary) + ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary, query) useClearResetErrorBoundary(errorResetBoundary) @@ -126,14 +135,7 @@ export function useBaseQuery< result, errorResetBoundary, throwOnError: defaultedOptions.throwOnError, - query: client - .getQueryCache() - .get< - TQueryFnData, - TError, - TQueryData, - TQueryKey - >(defaultedOptions.queryHash), + query, suspense: defaultedOptions.suspense, }) ) { @@ -154,7 +156,7 @@ export function useBaseQuery< ? // 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 - client.getQueryCache().get(defaultedOptions.queryHash)?.promise + query?.promise promise?.catch(noop).finally(() => { // `.updateResult()` will trigger `.#currentThenable` to finalize diff --git a/packages/preact-query/src/useQueries.ts b/packages/preact-query/src/useQueries.ts index 7e30658b843..dac252d0109 100644 --- a/packages/preact-query/src/useQueries.ts +++ b/packages/preact-query/src/useQueries.ts @@ -241,9 +241,10 @@ export function useQueries< [queries, client, isRestoring], ) - defaultedQueries.forEach((query) => { - ensureSuspenseTimers(query) - ensurePreventErrorBoundaryRetry(query, errorResetBoundary) + defaultedQueries.forEach((queryOptions) => { + ensureSuspenseTimers(queryOptions) + const query = client.getQueryCache().get(queryOptions.queryHash) + ensurePreventErrorBoundaryRetry(queryOptions, errorResetBoundary, query) }) useClearResetErrorBoundary(errorResetBoundary)