diff --git a/.changeset/angular-inject-queries-throw-on-error.md b/.changeset/angular-inject-queries-throw-on-error.md new file mode 100644 index 00000000000..2e8b1545782 --- /dev/null +++ b/.changeset/angular-inject-queries-throw-on-error.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': patch +--- + +fix(angular-query): honor per-query 'throwOnError' in 'injectQueries' diff --git a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts index 523bda69f06..a6ad1abd7a0 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-queries.test.ts @@ -180,6 +180,103 @@ describe('injectQueries', () => { expect(rendered.getByText('status2: success, data2: 2')).toBeInTheDocument() }) + describe('throwOnError', () => { + it('should throw when throwOnError is true', async () => { + const key1 = queryKey() + const key2 = queryKey() + + TestBed.runInInjectionContext(() => + injectQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(0).then(() => 1), + }, + { + queryKey: key2, + queryFn: () => + sleep(0).then(() => Promise.reject(new Error('Some error'))), + throwOnError: true, + }, + ], + })), + ) + + await expect(vi.runAllTimersAsync()).rejects.toThrow('Some error') + }) + + it('should keep the queries observable after throwing', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const result = TestBed.runInInjectionContext(() => + injectQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + }, + { + queryKey: key2, + queryFn: () => + sleep(20).then(() => Promise.reject(new Error('Some error'))), + retry: false, + throwOnError: true, + }, + ], + })), + ) + + await vi.advanceTimersByTimeAsync(11) + expect(result()[0].data()).toBe(1) + + await expect(vi.advanceTimersByTimeAsync(10)).rejects.toThrow( + 'Some error', + ) + + // `throwOnError` means "also throw", so the results the caller renders + // from must still be updated - including the sibling that succeeded. + expect(result()[0].data()).toBe(1) + expect(result()[1].status()).toBe('error') + expect(result()[1].error()).toEqual(Error('Some error')) + }) + + it('should evaluate throwOnError with the error and query of the failing query only', async () => { + const key1 = queryKey() + const key2 = queryKey() + const boundaryFn = vi.fn().mockReturnValue(false) + + TestBed.runInInjectionContext(() => + injectQueries(() => ({ + queries: [ + { + queryKey: key1, + queryFn: () => sleep(10).then(() => 1), + }, + { + queryKey: key2, + queryFn: () => + sleep(10).then(() => Promise.reject(new Error('Some error'))), + retry: false, + throwOnError: boundaryFn, + }, + ], + })), + ) + + await vi.advanceTimersByTimeAsync(11) + + expect(boundaryFn).toHaveBeenCalledTimes(1) + expect(boundaryFn).toHaveBeenCalledWith( + Error('Some error'), + expect.objectContaining({ + queryKey: key2, + state: expect.objectContaining({ status: 'error' }), + }), + ) + }) + }) + describe('isRestoring', () => { it('should not fetch for the duration of the restoring period when isRestoring is true', async () => { const key1 = queryKey() diff --git a/packages/angular-query-experimental/src/inject-queries.ts b/packages/angular-query-experimental/src/inject-queries.ts index d61a937a3c2..b56b770d994 100644 --- a/packages/angular-query-experimental/src/inject-queries.ts +++ b/packages/angular-query-experimental/src/inject-queries.ts @@ -2,6 +2,7 @@ import { QueriesObserver, QueryClient, notifyManager, + shouldThrowError, } from '@tanstack/query-core' import { DestroyRef, @@ -301,7 +302,32 @@ export function injectQueries< : ngZone.runOutsideAngular(() => observer.subscribe( notifyManager.batchCalls((state) => { + // Publish first: `throwOnError` means "also throw", so the + // results the caller renders from - the failing query's own + // error state included - must still be updated. Nothing below + // may leave the signal holding a stale notification. resultFromSubscriberSignal.set(getCombinedResult(state)) + + // Each query carries its own `throwOnError`, so the option is + // resolved per query against that query's own error. + const queryObservers = observer.getObservers() + const resultToThrow = state.find((result, index) => { + const queryObserver = queryObservers[index] + return ( + result.isError && + !result.isFetching && + !!queryObserver && + shouldThrowError(queryObserver.options.throwOnError, [ + result.error, + queryObserver.getCurrentQuery(), + ]) + ) + }) + + if (resultToThrow) { + ngZone.onError.emit(resultToThrow.error) + throw resultToThrow.error + } }), ), )