From bf43343b4c170e8015437590c5d777f083184060 Mon Sep 17 00:00:00 2001 From: Liang Xu <755674130@qq.com> Date: Tue, 29 Sep 2026 10:10:56 +0800 Subject: [PATCH] fix(angular-query): handle ResourceParamsStatus sentinel errors in optionsFn and toResource When query options read an Angular Resource or debounced signal parameter (such as in Signal Forms `validateAsync`), Angular can throw a sentinel `ResourceParamsStatus.LOADING` or `ResourceParamsStatus.IDLE` while the parameter is pending. Safely catch `ResourceParamsStatus` in `injectBaseQuery` so that the query suspends with `enabled: false` instead of leaking an unhandled effect error to the global console. Reflect the loading/idle state in `toResource` so that form async validators correctly track pending status without error. Co-Authored-By: Claude Code --- .../src/__tests__/upstream-issues.test.ts | 78 +++++++++++++++++++ .../angular-query/src/inject-base-query.ts | 69 +++++++++++++++- .../src/inject-infinite-query.ts | 13 +++- packages/angular-query/src/inject-query.ts | 9 ++- packages/angular-query/src/query-resource.ts | 21 +++++ .../src/utils/resource-params-status.ts | 41 ++++++++++ 6 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 packages/angular-query/src/utils/resource-params-status.ts diff --git a/packages/angular-query/src/__tests__/upstream-issues.test.ts b/packages/angular-query/src/__tests__/upstream-issues.test.ts index 39b33d520ea..4f979846680 100644 --- a/packages/angular-query/src/__tests__/upstream-issues.test.ts +++ b/packages/angular-query/src/__tests__/upstream-issues.test.ts @@ -2,7 +2,9 @@ import { ApplicationRef, ChangeDetectionStrategy, Component, + ResourceParamsStatus, VERSION, + computed, effect, input, signal, @@ -14,6 +16,7 @@ import { injectMutation, injectQuery, provideTanStackQuery, + toResource, } from '..' import { provideAngularQueryChangeDetection } from './test-utils' @@ -286,4 +289,79 @@ describe('upstream Angular issue reproductions', () => { expect(query.data()).toBe('updated') expect(query.isRefetching()).toBe(false) }) + + it('#11586: toResource handles debounced parameters throwing ResourceParamsStatus.LOADING or IDLE', async () => { + const param = signal(null) + const debouncedParam = computed(() => { + const val = param() + if (val === null) { + throw ResourceParamsStatus.LOADING + } + if (val === undefined) { + throw ResourceParamsStatus.IDLE + } + return val + }) + + const app = TestBed.inject(ApplicationRef) + const resource = TestBed.runInInjectionContext(() => { + const query = injectQuery(() => { + const code = debouncedParam() + return { + queryKey: ['11586', code], + queryFn: () => Promise.resolve(`result-${code}`), + } + }) + return toResource(query) + }) + + TestBed.tick() + expect(resource.status()).toBe('loading') + expect(resource.isLoading()).toBe(true) + + param.set(undefined) + TestBed.tick() + expect(resource.status()).toBe('idle') + expect(resource.isLoading()).toBe(false) + + param.set('settled') + TestBed.tick() + await app.whenStable() + + expect(resource.status()).toBe('resolved') + expect(resource.value()).toBe('result-settled') + }) + + it('#11586: injectQuery gracefully suspends when optionsFn throws ResourceParamsStatus without unhandled effect errors', async () => { + const param = signal(null) + const debouncedParam = computed(() => { + const val = param() + if (val === null) { + throw ResourceParamsStatus.LOADING + } + return val + }) + + const app = TestBed.inject(ApplicationRef) + const query = TestBed.runInInjectionContext(() => + injectQuery(() => { + const code = debouncedParam() + return { + queryKey: ['11586-bare', code], + queryFn: () => Promise.resolve(`data-${code}`), + } + }), + ) + + TestBed.tick() + expect(query.isPending()).toBe(true) + expect(query.fetchStatus()).toBe('idle') + + param.set('ready') + TestBed.tick() + await app.whenStable() + + expect(query.isSuccess()).toBe(true) + expect(query.data()).toBe('data-ready') + }) }) diff --git a/packages/angular-query/src/inject-base-query.ts b/packages/angular-query/src/inject-base-query.ts index d36f9f30c24..423faf04d7e 100644 --- a/packages/angular-query/src/inject-base-query.ts +++ b/packages/angular-query/src/inject-base-query.ts @@ -4,6 +4,7 @@ import { injectQueryZone } from './utils/inject-query-zone' import { injectIsRestoring } from './inject-is-restoring' import { injectPendingTasksLifecycle } from './utils/inject-pending-tasks-lifecycle' import { injectExternalStore } from './utils/inject-external-store' +import { getResourceParamsStatus } from './utils/resource-params-status' import type { QueryKey, QueryObserver, @@ -45,12 +46,71 @@ export function injectBaseQuery< ) } + let lastSuccessfulOptions: + | QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + > + | undefined + + const evaluatedOptionsSignal = computed(() => { + try { + const options = optionsFn() + lastSuccessfulOptions = options + return { + options, + resourceParamsStatus: undefined, + error: undefined, + } + } catch (error) { + const resourceParamsStatus = getResourceParamsStatus(error) + if (resourceParamsStatus) { + return { + options: undefined, + resourceParamsStatus, + error, + } + } + throw error + } + }) + + const resourceParamsStatusSignal = computed( + () => evaluatedOptionsSignal().resourceParamsStatus, + ) + const resourceParamsErrorSignal = computed( + () => evaluatedOptionsSignal().error, + ) + const defaultedOptionsSignal = computed(() => { - const defaultedOptions = queryClient.defaultQueryOptions(optionsFn()) + const evaluated = evaluatedOptionsSignal() + const baseOptions = + evaluated.options ?? + lastSuccessfulOptions ?? + ({ + queryKey: [ + '__tanstack_query_resource_pending__', + ] as unknown as TQueryKey, + enabled: false, + } as QueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >) + + const defaultedOptions = queryClient.defaultQueryOptions(baseOptions) defaultedOptions._optimisticResults = isRestoring() ? 'isRestoring' : 'optimistic' defaultedOptions.notifyOnChangeProps = 'all' + if (evaluated.resourceParamsStatus) { + defaultedOptions.enabled = false + } return defaultedOptions }) @@ -103,5 +163,10 @@ export function injectBaseQuery< return observer }) - return [resultSignal, getObserver] as const + return [ + resultSignal, + getObserver, + resourceParamsStatusSignal, + resourceParamsErrorSignal, + ] as const } diff --git a/packages/angular-query/src/inject-infinite-query.ts b/packages/angular-query/src/inject-infinite-query.ts index f557fc1875c..55e17a6a541 100644 --- a/packages/angular-query/src/inject-infinite-query.ts +++ b/packages/angular-query/src/inject-infinite-query.ts @@ -2,6 +2,10 @@ import { InfiniteQueryObserver } from '@tanstack/query-core' import { assertInInjectionContext, untracked } from '@angular/core' import { injectQueryZone } from './utils/inject-query-zone' import { injectBaseQuery } from './inject-base-query' +import { + RESOURCE_PARAMS_ERROR, + RESOURCE_PARAMS_STATUS, +} from './utils/resource-params-status' import { signalProxy } from './utils/signal-proxy' import { infiniteQueryResultFields } from './utils/result-fields' import type { @@ -163,7 +167,12 @@ export function injectInfiniteQuery< assertInInjectionContext(injectInfiniteQuery) } const outsideZone = injectQueryZone() - const [resultSignal, getObserver] = injectBaseQuery( + const [ + resultSignal, + getObserver, + resourceParamsStatus, + resourceParamsError, + ] = injectBaseQuery( optionsFn, InfiniteQueryObserver as typeof QueryObserver, ) @@ -193,6 +202,8 @@ export function injectInfiniteQuery< outsideZone(() => untracked(() => getInfiniteObserver().fetchPreviousPage(options)), ), + [RESOURCE_PARAMS_STATUS]: resourceParamsStatus, + [RESOURCE_PARAMS_ERROR]: resourceParamsError, }, ) as unknown as | DefinedCreateInfiniteQueryResult diff --git a/packages/angular-query/src/inject-query.ts b/packages/angular-query/src/inject-query.ts index f2dfe752971..e24da58c395 100644 --- a/packages/angular-query/src/inject-query.ts +++ b/packages/angular-query/src/inject-query.ts @@ -2,6 +2,10 @@ import { QueryObserver } from '@tanstack/query-core' import { assertInInjectionContext, untracked } from '@angular/core' import { injectQueryZone } from './utils/inject-query-zone' import { injectBaseQuery } from './inject-base-query' +import { + RESOURCE_PARAMS_ERROR, + RESOURCE_PARAMS_STATUS, +} from './utils/resource-params-status' import { signalProxy } from './utils/signal-proxy' import { queryResultFields } from './utils/result-fields' import type { @@ -184,9 +188,12 @@ export function injectQuery( assertInInjectionContext(injectQuery) } const outsideZone = injectQueryZone() - const [resultSignal, getObserver] = injectBaseQuery(optionsFn, QueryObserver) + const [resultSignal, getObserver, resourceParamsStatus, resourceParamsError] = + injectBaseQuery(optionsFn, QueryObserver) return Object.assign(signalProxy(resultSignal, queryResultFields), { refetch: (options?: RefetchOptions) => outsideZone(() => untracked(() => getObserver().refetch(options))), + [RESOURCE_PARAMS_STATUS]: resourceParamsStatus, + [RESOURCE_PARAMS_ERROR]: resourceParamsError, }) as unknown as DefinedCreateQueryResult | CreateQueryResult } diff --git a/packages/angular-query/src/query-resource.ts b/packages/angular-query/src/query-resource.ts index 33f47894cc5..82db0e76192 100644 --- a/packages/angular-query/src/query-resource.ts +++ b/packages/angular-query/src/query-resource.ts @@ -1,4 +1,8 @@ import { computed, untracked } from '@angular/core' +import { + RESOURCE_PARAMS_ERROR, + RESOURCE_PARAMS_STATUS, +} from './utils/resource-params-status' import type { QueryObserverResult } from '@tanstack/query-core' import type { ResourceSnapshot } from './utils/resource-types' import type { Resource, ResourceStatus, Signal } from '@angular/core' @@ -43,6 +47,17 @@ export function toResource( // Intentionally does not use resource from snapshot to have // support for the reload method for compatible libraries const status = computed(() => { + const paramsStatus = (query as any)[RESOURCE_PARAMS_STATUS]?.() + if (paramsStatus === 'loading') { + return query.data() === undefined ? 'loading' : 'reloading' + } + if (paramsStatus === 'idle') { + return 'idle' + } + if (paramsStatus === 'error') { + return 'error' + } + const fetchStatus = query.fetchStatus() const data = query.data() @@ -61,6 +76,12 @@ export function toResource( }) const error = computed(() => { + const paramsStatus = (query as any)[RESOURCE_PARAMS_STATUS]?.() + if (paramsStatus === 'error') { + const paramsError = (query as any)[RESOURCE_PARAMS_ERROR]?.() + return paramsError ? normalizeError(paramsError) : undefined + } + return query.status() === 'error' ? normalizeError(query.error()) : undefined diff --git a/packages/angular-query/src/utils/resource-params-status.ts b/packages/angular-query/src/utils/resource-params-status.ts new file mode 100644 index 00000000000..4a1a4b447e6 --- /dev/null +++ b/packages/angular-query/src/utils/resource-params-status.ts @@ -0,0 +1,41 @@ +import * as ngCore from '@angular/core' + +export const RESOURCE_PARAMS_STATUS = Symbol('RESOURCE_PARAMS_STATUS') +export const RESOURCE_PARAMS_ERROR = Symbol('RESOURCE_PARAMS_ERROR') + +export type ResourceParamsStatusType = 'loading' | 'idle' | 'error' + +export function getResourceParamsStatus( + error: unknown, +): ResourceParamsStatusType | undefined { + if (!error) return undefined + + const ResourceParamsStatus = (ngCore as any).ResourceParamsStatus + if (ResourceParamsStatus) { + if (error === ResourceParamsStatus.LOADING) return 'loading' + if (error === ResourceParamsStatus.IDLE) return 'idle' + } + + const ResourceDependencyError = (ngCore as any).ResourceDependencyError + if (ResourceDependencyError && error instanceof ResourceDependencyError) { + return 'error' + } + + if (error instanceof Error) { + if ( + error.constructor.name === 'ResourceParamsStatus' || + (error as any)._brand !== undefined + ) { + if (error.message === 'LOADING') return 'loading' + if (error.message === 'IDLE') return 'idle' + } + if ( + error.constructor.name === 'ResourceDependencyError' || + error.name === 'ResourceDependencyError' + ) { + return 'error' + } + } + + return undefined +}