From f446e8533c3a534539b3917e2a85b417f0ebbbf2 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Sat, 5 Sep 2026 08:40:18 +0000 Subject: [PATCH] fix(angular-query): honor per-query 'throwOnError' in 'injectQueries' injectQueries never evaluated throwOnError, so a failing query with throwOnError set was silently reduced to an error result. Resolve the option per query in the QueriesObserver subscriber and rethrow, the way injectQuery already does in createBaseQuery. Results are published to the signal before the throw so the failing query's own error state and its healthy siblings still update. --- .../angular-inject-queries-throw-on-error.md | 5 + .../src/__tests__/inject-queries.test.ts | 97 +++++++++++++++++++ .../src/inject-queries.ts | 26 +++++ 3 files changed, 128 insertions(+) create mode 100644 .changeset/angular-inject-queries-throw-on-error.md 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 + } }), ), )