Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/angular-query/src/__tests__/upstream-issues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {
ApplicationRef,
ChangeDetectionStrategy,
Component,
ResourceParamsStatus,
VERSION,
computed,
effect,
input,
signal,
Expand All @@ -14,6 +16,7 @@ import {
injectMutation,
injectQuery,
provideTanStackQuery,
toResource,
} from '..'
import { provideAngularQueryChangeDetection } from './test-utils'

Expand Down Expand Up @@ -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<string | null | undefined>(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<string | null>(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')
})
})
69 changes: 67 additions & 2 deletions packages/angular-query/src/inject-base-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
})

Expand Down Expand Up @@ -103,5 +163,10 @@ export function injectBaseQuery<
return observer
})

return [resultSignal, getObserver] as const
return [
resultSignal,
getObserver,
resourceParamsStatusSignal,
resourceParamsErrorSignal,
] as const
}
13 changes: 12 additions & 1 deletion packages/angular-query/src/inject-infinite-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -193,6 +202,8 @@ export function injectInfiniteQuery<
outsideZone(() =>
untracked(() => getInfiniteObserver().fetchPreviousPage(options)),
),
[RESOURCE_PARAMS_STATUS]: resourceParamsStatus,
[RESOURCE_PARAMS_ERROR]: resourceParamsError,
},
) as unknown as
| DefinedCreateInfiniteQueryResult<TData, TError>
Expand Down
9 changes: 8 additions & 1 deletion packages/angular-query/src/inject-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
21 changes: 21 additions & 0 deletions packages/angular-query/src/query-resource.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -43,6 +47,17 @@ export function toResource<TValue>(
// Intentionally does not use resource from snapshot to have
// support for the reload method for compatible libraries
const status = computed<ResourceStatus>(() => {
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()

Expand All @@ -61,6 +76,12 @@ export function toResource<TValue>(
})

const error = computed<Error | undefined>(() => {
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
Expand Down
41 changes: 41 additions & 0 deletions packages/angular-query/src/utils/resource-params-status.ts
Original file line number Diff line number Diff line change
@@ -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
}