diff --git a/.changeset/little-parks-arrive.md b/.changeset/little-parks-arrive.md new file mode 100644 index 0000000000..84d4e05160 --- /dev/null +++ b/.changeset/little-parks-arrive.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-query-experimental': minor +--- + +Refactor base query to no longer rely on the execution of effects diff --git a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts index 2f541788ab..a654a69ac6 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -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() @@ -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) @@ -743,9 +740,7 @@ 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') @@ -753,14 +748,9 @@ describe('injectQuery', () => { // 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) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index 4daede7684..2596a075d5 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -1,6 +1,6 @@ import { + DestroyRef, NgZone, - VERSION, computed, effect, inject, @@ -9,6 +9,7 @@ import { } from '@angular/core' import { QueryClient, + noop, notifyManager, shouldThrowError, } from '@tanstack/query-core' @@ -16,11 +17,7 @@ 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' /** @@ -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 @@ -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 @@ -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 | 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() @@ -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()