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
5 changes: 5 additions & 0 deletions .changeset/little-parks-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/angular-query-experimental': minor
---

Refactor base query to no longer rely on the execution of effects
Original file line number Diff line number Diff line change
Expand Up @@ -659,18 +659,15 @@ describe('injectQuery', () => {
})),
)

// Synchronize pending effects
TestBed.tick()
await vi.runAllTimersAsync()

const stablePromise = app.whenStable()
await stablePromise
await app.whenStable()

expect(query.status()).toBe('success')
expect(query.data()).toBe('sync-data-1')
expect(callCount).toBe(1)

await query.refetch()
await Promise.resolve()
await vi.runAllTimersAsync()
await app.whenStable()

Expand Down Expand Up @@ -703,18 +700,18 @@ describe('injectQuery', () => {
})),
)

// Initially disabled
TestBed.tick()
await app.whenStable()

expect(query.status()).toBe('pending')
expect(query.data()).toBeUndefined()
expect(callCount).toBe(0)

// Enable the query
enabledSignal.set(true)
TestBed.tick()

await vi.runOnlyPendingTimersAsync()
await app.whenStable()

expect(query.status()).toBe('success')
expect(query.data()).toBe('sync-data-1')
expect(callCount).toBe(1)
Expand Down Expand Up @@ -743,24 +740,17 @@ describe('injectQuery', () => {
})),
)

// Synchronize pending effects
TestBed.tick()

await vi.runAllTimersAsync()
await app.whenStable()
expect(query.status()).toBe('success')
expect(query.data()).toBe('sync-data-1')
expect(callCount).toBe(1)

// Invalidate the query
queryClient.invalidateQueries({ queryKey: testKey })
TestBed.tick()

// Wait for the invalidation to trigger a refetch
await Promise.resolve()
await vi.advanceTimersByTimeAsync(10)
TestBed.tick()

await app.whenStable()

expect(query.status()).toBe('success')
expect(query.data()).toBe('sync-data-2')
expect(callCount).toBe(2)
Expand Down
122 changes: 67 additions & 55 deletions packages/angular-query-experimental/src/create-base-query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
DestroyRef,
NgZone,
VERSION,
computed,
effect,
inject,
Expand All @@ -9,18 +9,15 @@ import {
} from '@angular/core'
import {
QueryClient,
noop,
notifyManager,
shouldThrowError,
} from '@tanstack/query-core'
import { signalProxy } from './signal-proxy'
import { injectIsRestoring } from './inject-is-restoring'
import { PENDING_TASKS } from './pending-tasks-compat'
import type { PendingTaskRef } from './pending-tasks-compat'
import type {
QueryKey,
QueryObserver,
QueryObserverResult,
} from '@tanstack/query-core'
import type { QueryKey, QueryObserver } from '@tanstack/query-core'
import type { CreateBaseQueryOptions } from './types'

/**
Expand All @@ -44,10 +41,11 @@ export function createBaseQuery<
>,
Observer: typeof QueryObserver,
) {
const destroyRef = inject(DestroyRef)
const ngZone = inject(NgZone)
const pendingTasks = inject(PENDING_TASKS)
const queryClient = inject(QueryClient)
const isRestoring = injectIsRestoring()
const isRestoringSignal = injectIsRestoring()

/**
* Signal that has the default options from query client applied
Expand All @@ -57,7 +55,7 @@ export function createBaseQuery<
*/
const defaultedOptionsSignal = computed(() => {
const defaultedOptions = queryClient.defaultQueryOptions(optionsFn())
defaultedOptions._optimisticResults = isRestoring()
defaultedOptions._optimisticResults = isRestoringSignal()
? 'isRestoring'
: 'optimistic'
return defaultedOptions
Expand All @@ -73,49 +71,51 @@ export function createBaseQuery<
> | null = null

return computed(() => {
return (instance ||= new Observer(queryClient, defaultedOptionsSignal()))
const observerOptions = defaultedOptionsSignal()
return untracked(() => {
if (instance) {
instance.setOptions(observerOptions)
} else {
instance = new Observer(queryClient, observerOptions)
}
return instance
})
})
})()

const optimisticResultSignal = computed(() =>
observerSignal().getOptimisticResult(defaultedOptionsSignal()),
)
let cleanup: () => void = noop
let pendingTaskRef: PendingTaskRef | null = null

const resultFromSubscriberSignal = signal<QueryObserverResult<
TData,
TError
> | null>(null)
/**
* Returning a writable signal from a computed is similar to `linkedSignal`,
* but compatible with Angular < 19
*
* Compared to `linkedSignal`, this pattern requires extra parentheses:
* - Accessing value: `result()()`
* - Setting value: `result().set(newValue)`
*/
const linkedResultSignal = computed(() => {
const observer = observerSignal()
const defaultedOptions = defaultedOptionsSignal()
const isRestoring = isRestoringSignal()

effect(
(onCleanup) => {
const observer = observerSignal()
const defaultedOptions = defaultedOptionsSignal()
return untracked(() => {
// observer.trackResult is not used as this optimization is not needed for Angular
const currentResult = observer.getOptimisticResult(defaultedOptions)
const result = signal(currentResult)

untracked(() => {
observer.setOptions(defaultedOptions)
})
onCleanup(() => {
ngZone.run(() => resultFromSubscriberSignal.set(null))
})
},
{
// Set allowSignalWrites to support Angular < v19
// Set to undefined to avoid warning on newer versions
allowSignalWrites: VERSION.major < '19' || undefined,
},
)
cleanup()

effect((onCleanup) => {
// observer.trackResult is not used as this optimization is not needed for Angular
const observer = observerSignal()
let pendingTaskRef: PendingTaskRef | null = null
if (currentResult.fetchStatus === 'fetching' && !pendingTaskRef) {
pendingTaskRef = pendingTasks.add()
}

const unsubscribe = isRestoring()
? () => undefined
: untracked(() =>
ngZone.runOutsideAngular(() => {
return observer.subscribe(
const unsubscribe = isRestoring
? noop
: ngZone.runOutsideAngular(() =>
observer.subscribe(
notifyManager.batchCalls((state) => {
result.set(state)
ngZone.run(() => {
if (state.fetchStatus === 'fetching' && !pendingTaskRef) {
pendingTaskRef = pendingTasks.add()
Expand All @@ -137,27 +137,39 @@ export function createBaseQuery<
ngZone.onError.emit(state.error)
throw state.error
}
resultFromSubscriberSignal.set(state)
})
}),
)
}),
)

onCleanup(() => {
if (pendingTaskRef) {
pendingTaskRef()
pendingTaskRef = null
),
)

cleanup = () => {
unsubscribe()
if (pendingTaskRef) {
pendingTaskRef()
pendingTaskRef = null
}
}
unsubscribe()

return result
})
})

destroyRef.onDestroy(() => cleanup())

/**
* This effect is responsible for triggering
* the query by listing to the result.
*
* If this effect was removed, queries would
* be executed lazily on read.
*/
effect(() => {
linkedResultSignal()
})

return signalProxy(
computed(() => {
const subscriberResult = resultFromSubscriberSignal()
const optimisticResult = optimisticResultSignal()
const result = subscriberResult ?? optimisticResult
const result = linkedResultSignal()()

// Wrap methods to ensure observer has latest options before execution
const observer = observerSignal()
Expand Down