Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 executation 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