From 67d1705944563bffd5fb09ca4874f86e9d4b6c12 Mon Sep 17 00:00:00 2001 From: Arnoud <6420061+arnoud-dv@users.noreply.github.com> Date: Sun, 8 Dec 2024 16:41:43 +0100 Subject: [PATCH] fix(angular-query): improve support for required signals (#8409) --- .../__tests__/inject-mutation-state.test.ts | 9 +- .../src/__tests__/inject-mutation.test.ts | 9 +- .../src/__tests__/inject-query.test.ts | 39 ++--- .../util/lazy-init/lazy-init.test.ts | 122 -------------- .../lazy-signal-initializer.test.ts | 126 -------------- .../src/create-base-query.ts | 110 +++++++----- .../src/inject-mutation-state.ts | 87 +++++----- .../src/inject-mutation.ts | 157 +++++++++++------- .../src/providers.ts | 1 + .../src/util/lazy-init/lazy-init.ts | 34 ---- .../lazy-signal-initializer.ts | 23 --- 11 files changed, 238 insertions(+), 479 deletions(-) delete mode 100644 packages/angular-query-experimental/src/__tests__/util/lazy-init/lazy-init.test.ts delete mode 100644 packages/angular-query-experimental/src/__tests__/util/lazy-signal-initializer/lazy-signal-initializer.test.ts delete mode 100644 packages/angular-query-experimental/src/util/lazy-init/lazy-init.ts delete mode 100644 packages/angular-query-experimental/src/util/lazy-signal-initializer/lazy-signal-initializer.ts diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts index f7e6cf20db..90360ee443 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts @@ -30,7 +30,7 @@ describe('injectMutationState', () => { }) describe('injectMutationState', () => { - test('should return variables after calling mutate 1', async () => { + test('should return variables after calling mutate 1', () => { const mutationKey = ['mutation'] const variables = 'foo123' @@ -53,7 +53,7 @@ describe('injectMutationState', () => { expect(mutationState()).toEqual([variables]) }) - test('reactive options should update injectMutationState', async () => { + test('reactive options should update injectMutationState', () => { const mutationKey1 = ['mutation1'] const mutationKey2 = ['mutation2'] const variables1 = 'foo123' @@ -87,11 +87,10 @@ describe('injectMutationState', () => { expect(mutationState()).toEqual([variables1]) filterKey.set(mutationKey2) - TestBed.flushEffects() expect(mutationState()).toEqual([variables2]) }) - test('should return variables after calling mutate 2', async () => { + test('should return variables after calling mutate 2', () => { queryClient.clear() const mutationKey = ['mutation'] const variables = 'bar234' @@ -156,8 +155,6 @@ describe('injectMutationState', () => { const { debugElement } = fixture setFixtureSignalInputs(fixture, { name: fakeName }) - fixture.detectChanges() - let spans = debugElement .queryAll(By.css('span')) .map((span) => span.nativeNode.textContent) diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts index d44d1c40ad..66d061b556 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -51,7 +51,7 @@ describe('injectMutation', () => { }) }) - test('should change state after invoking mutate', async () => { + test('should change state after invoking mutate', () => { const result = 'Mock data' const mutation = TestBed.runInInjectionContext(() => { @@ -60,6 +60,8 @@ describe('injectMutation', () => { })) }) + TestBed.flushEffects() + mutation.mutate(result) vi.advanceTimersByTime(1) @@ -79,6 +81,7 @@ describe('injectMutation', () => { mutationFn: errorMutator, })) }) + mutation.mutate({}) await resolveMutations() @@ -129,8 +132,6 @@ describe('injectMutation', () => { mutationKey.set(['2']) - TestBed.flushEffects() - mutation.mutate('xyz') const mutations = mutationCache.find({ mutationKey: ['2'] }) @@ -405,6 +406,8 @@ describe('injectMutation', () => { })) }) + TestBed.flushEffects() + mutate() await resolveMutations() 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 f9a7dee650..a70d59e8c2 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-query.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-query.test.ts @@ -78,7 +78,7 @@ describe('injectQuery', () => { const withResultInfer = TestBed.runInInjectionContext(() => injectQuery(() => ({ queryKey: key, - queryFn: async () => true, + queryFn: () => true, })), ) expectTypeOf(withResultInfer.data()).toEqualTypeOf() @@ -263,8 +263,6 @@ describe('injectQuery', () => { expect(query.isFetching()).toBe(true) expect(query.isStale()).toBe(true) expect(query.isFetched()).toBe(false) - - flush() })) test('should resolve to success and update signal: injectQuery()', fakeAsync(() => { @@ -275,7 +273,7 @@ describe('injectQuery', () => { })) }) - flush() + tick() expect(query.status()).toBe('success') expect(query.data()).toBe('result2') @@ -294,7 +292,7 @@ describe('injectQuery', () => { })) }) - flush() + tick() expect(query.status()).toBe('error') expect(query.data()).toBe(undefined) @@ -316,7 +314,7 @@ describe('injectQuery', () => { queryFn: spy, })) }) - flush() + tick() expect(spy).toHaveBeenCalledTimes(1) expect(query.status()).toBe('success') @@ -331,7 +329,6 @@ describe('injectQuery', () => { queryKey: ['key8'], signal: expect.anything(), }) - flush() })) test('should only run query once enabled signal is set to true', fakeAsync(() => { @@ -350,8 +347,7 @@ describe('injectQuery', () => { expect(query.status()).toBe('pending') enabled.set(true) - TestBed.flushEffects() - flush() + tick() expect(spy).toHaveBeenCalledTimes(1) expect(query.status()).toBe('success') })) @@ -381,7 +377,6 @@ describe('injectQuery', () => { expect(dependentQueryFn).not.toHaveBeenCalled() tick() - TestBed.flushEffects() expect(query1.data()).toStrictEqual('Some data') expect(query2.fetchStatus()).toStrictEqual('fetching') @@ -419,7 +414,7 @@ describe('injectQuery', () => { ) }) - flush() + tick() keySignal.set('key12') @@ -433,8 +428,6 @@ describe('injectQuery', () => { }), ) }) - - flush() })) describe('throwOnError', () => { @@ -471,7 +464,6 @@ describe('injectQuery', () => { expect(() => { flush() }).toThrowError('Some error') - flush() })) test('should throw when throwOnError function returns true', fakeAsync(() => { @@ -486,7 +478,6 @@ describe('injectQuery', () => { expect(() => { flush() }).toThrowError('Some error') - flush() })) }) @@ -501,12 +492,12 @@ describe('injectQuery', () => { expect(query.status()).toBe('pending') - flush() + tick() expect(query.status()).toBe('error') })) - test('should render with required signal inputs', fakeAsync(async () => { + test('should render with required signal inputs', fakeAsync(() => { @Component({ selector: 'app-fake', template: `{{ query.data() }}`, @@ -517,7 +508,7 @@ describe('injectQuery', () => { query = injectQuery(() => ({ queryKey: ['fake', this.name()], - queryFn: () => Promise.resolve(this.name()), + queryFn: () => this.name(), })) } @@ -526,10 +517,10 @@ describe('injectQuery', () => { name: 'signal-input-required-test', }) - flush() fixture.detectChanges() + tick() - expect(fixture.debugElement.nativeElement.textContent).toEqual( + expect(fixture.componentInstance.query.data()).toEqual( 'signal-input-required-test', ) })) @@ -565,13 +556,13 @@ describe('injectQuery', () => { const fixture = TestBed.createComponent(FakeComponent) fixture.detectChanges() - flush() + tick() expect(fixture.componentInstance.query.data()).toEqual('test name') fixture.componentInstance.name.set('test name 2') fixture.detectChanges() - flush() + tick() expect(fixture.componentInstance.query.data()).toEqual('test name 2') })) @@ -608,13 +599,13 @@ describe('injectQuery', () => { const fixture = TestBed.createComponent(FakeComponent) fixture.detectChanges() - flush() + tick() expect(fixture.componentInstance.query.data()).toEqual('test name') fixture.componentInstance.name.set('test name 2') fixture.detectChanges() - flush() + tick() expect(fixture.componentInstance.query.data()).toEqual('test name 2') })) diff --git a/packages/angular-query-experimental/src/__tests__/util/lazy-init/lazy-init.test.ts b/packages/angular-query-experimental/src/__tests__/util/lazy-init/lazy-init.test.ts deleted file mode 100644 index 18220d5934..0000000000 --- a/packages/angular-query-experimental/src/__tests__/util/lazy-init/lazy-init.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { - ChangeDetectionStrategy, - Component, - computed, - effect, - input, - signal, -} from '@angular/core' -import { TestBed } from '@angular/core/testing' -import { setFixtureSignalInputs } from '../../test-utils' -import { lazyInit } from '../../../util/lazy-init/lazy-init' -import type { WritableSignal } from '@angular/core' - -describe('lazyInit', () => { - test('should init lazily in next tick when not accessing manually', async () => { - const mockFn = vi.fn() - - TestBed.runInInjectionContext(() => { - lazyInit(() => { - mockFn() - return { - data: signal(true), - } - }) - }) - - expect(mockFn).not.toHaveBeenCalled() - - await new Promise(setImmediate) - - expect(mockFn).toHaveBeenCalled() - }) - - test('should init eagerly accessing manually', async () => { - const mockFn = vi.fn() - - TestBed.runInInjectionContext(() => { - const lazySignal = lazyInit(() => { - mockFn() - return { - data: signal(true), - } - }) - - lazySignal.data() - }) - - expect(mockFn).toHaveBeenCalled() - }) - - test('should init lazily and only once', async () => { - const initCallFn = vi.fn() - const registerDataValue = vi.fn<(arg: number) => any>() - - let value!: { data: WritableSignal } - const outerSignal = signal(0) - - TestBed.runInInjectionContext(() => { - value = lazyInit(() => { - initCallFn() - - void outerSignal() - - return { data: signal(0) } - }) - - effect(() => registerDataValue(value.data())) - }) - - value.data() - - expect(outerSignal).toBeDefined() - - expect(initCallFn).toHaveBeenCalledTimes(1) - - outerSignal.set(1) - - TestBed.flushEffects() - - outerSignal.set(2) - value.data.set(4) - TestBed.flushEffects() - - expect(initCallFn).toHaveBeenCalledTimes(1) - expect(registerDataValue).toHaveBeenCalledTimes(2) - }) - - test('should support required signal input', async () => { - @Component({ - standalone: true, - template: `{{ call }} - {{ lazySignal.data() }}`, - changeDetection: ChangeDetectionStrategy.OnPush, - }) - class Test { - readonly title = input.required() - call = 0 - - lazySignal = lazyInit(() => { - this.call++ - return { - data: computed(() => this.title()), - } - }) - } - - const fixture = TestBed.createComponent(Test) - - setFixtureSignalInputs(fixture, { title: 'newValue' }) - expect(fixture.debugElement.nativeElement.textContent).toBe('0 - newValue') - - setFixtureSignalInputs(fixture, { title: 'updatedValue' }) - expect(fixture.debugElement.nativeElement.textContent).toBe( - '1 - updatedValue', - ) - - setFixtureSignalInputs(fixture, { title: 'newUpdatedValue' }) - expect(fixture.debugElement.nativeElement.textContent).toBe( - '1 - newUpdatedValue', - ) - }) -}) diff --git a/packages/angular-query-experimental/src/__tests__/util/lazy-signal-initializer/lazy-signal-initializer.test.ts b/packages/angular-query-experimental/src/__tests__/util/lazy-signal-initializer/lazy-signal-initializer.test.ts deleted file mode 100644 index 52081c6064..0000000000 --- a/packages/angular-query-experimental/src/__tests__/util/lazy-signal-initializer/lazy-signal-initializer.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { Component, effect, input, signal } from '@angular/core' -import { TestBed } from '@angular/core/testing' -import { lazySignalInitializer } from '../../../util/lazy-signal-initializer/lazy-signal-initializer' -import { setFixtureSignalInputs } from '../../test-utils' -import type { Signal, WritableSignal } from '@angular/core' - -describe('lazySignalInitializer', () => { - test('should init lazily in next tick when not accessing manually', async () => { - const mockFn = vi.fn() - - TestBed.runInInjectionContext(() => { - lazySignalInitializer(() => { - mockFn() - return signal(true) - }) - }) - - expect(mockFn).not.toHaveBeenCalled() - - await new Promise(setImmediate) - - expect(mockFn).toHaveBeenCalled() - }) - - test('should init eagerly accessing manually', async () => { - const mockFn = vi.fn() - - TestBed.runInInjectionContext(() => { - const lazySignal = lazySignalInitializer(() => { - mockFn() - return signal(true) - }) - - lazySignal() - }) - - expect(mockFn).toHaveBeenCalled() - }) - - test('should init lazily and only once', async () => { - const initCallFn = vi.fn() - const registerEffectValue = vi.fn<(arg: number) => any>() - - let value!: Signal - const outerSignal = signal(0) - let innerSignal!: WritableSignal - - TestBed.runInInjectionContext(() => { - value = lazySignalInitializer(() => { - initCallFn() - innerSignal = signal(0) - - void outerSignal() - - return innerSignal - }) - - effect(() => registerEffectValue(value())) - }) - - value() - - TestBed.flushEffects() - - expect(outerSignal).toBeDefined() - expect(innerSignal).toBeDefined() - - expect(registerEffectValue).toHaveBeenCalledTimes(1) - - expect(initCallFn).toHaveBeenCalledTimes(1) - - innerSignal.set(1) - outerSignal.set(2) - - TestBed.flushEffects() - - expect(initCallFn).toHaveBeenCalledTimes(1) - expect(registerEffectValue).toHaveBeenCalledTimes(2) - }) - - test('should init lazily', async () => { - @Component({ - standalone: true, - template: `{{ subscribed }}`, - }) - class Test { - subscribed = false - - lazySignal = lazySignalInitializer(() => { - this.subscribed = true - return signal('value') - }) - } - - const fixture = TestBed.createComponent(Test) - const { debugElement } = fixture - fixture.detectChanges() - - expect(debugElement.nativeElement.textContent).toBe('false') - - await new Promise(setImmediate) - - fixture.detectChanges() - - expect(debugElement.nativeElement.textContent).toBe('true') - }) - - test('should support required signal input', () => { - @Component({ - standalone: true, - template: `{{ subscribed }}`, - }) - class Test { - readonly title = input.required() - subscribed = false - - lazySignal = lazySignalInitializer(() => { - return signal(this.title()) - }) - } - - const fixture = TestBed.createComponent(Test) - setFixtureSignalInputs(fixture, { title: 'newValue' }) - }) -}) diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index c44aea2317..ad927a28d7 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -12,7 +12,6 @@ import { import { QueryClient, notifyManager } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' import { shouldThrowError } from './util' -import { lazyInit } from './util/lazy-init/lazy-init' import type { QueryKey, QueryObserver, @@ -40,58 +39,75 @@ export function createBaseQuery< Observer: typeof QueryObserver, ) { const injector = inject(Injector) - return lazyInit(() => { - const ngZone = injector.get(NgZone) - const destroyRef = injector.get(DestroyRef) - const queryClient = injector.get(QueryClient) + const ngZone = injector.get(NgZone) + const destroyRef = injector.get(DestroyRef) + const queryClient = injector.get(QueryClient) - /** - * Signal that has the default options from query client applied - * computed() is used so signals can be inserted into the options - * making it reactive. Wrapping options in a function ensures embedded expressions - * are preserved and can keep being applied after signal changes - */ - const defaultedOptionsSignal = computed(() => { - const options = runInInjectionContext(injector, () => optionsFn()) - const defaultedOptions = queryClient.defaultQueryOptions(options) - defaultedOptions._optimisticResults = 'optimistic' - return defaultedOptions - }) + /** + * Signal that has the default options from query client applied + * computed() is used so signals can be inserted into the options + * making it reactive. Wrapping options in a function ensures embedded expressions + * are preserved and can keep being applied after signal changes + */ + const defaultedOptionsSignal = computed(() => { + const options = runInInjectionContext(injector, () => optionsFn()) + const defaultedOptions = queryClient.defaultQueryOptions(options) + defaultedOptions._optimisticResults = 'optimistic' + return defaultedOptions + }) - const observer = new Observer< + const observerSignal = (() => { + let instance: QueryObserver< TQueryFnData, TError, TData, TQueryData, TQueryKey - >(queryClient, defaultedOptionsSignal()) + > | null = null + + return computed(() => { + return (instance ||= new Observer(queryClient, defaultedOptionsSignal())) + }) + })() + + const optimisticResultSignal = computed(() => + observerSignal().getOptimisticResult(defaultedOptionsSignal()), + ) + + const resultFromSubscriberSignal = signal | null>(null) - const resultSignal = signal( - observer.getOptimisticResult(defaultedOptionsSignal()), - ) + effect( + (onCleanup) => { + const observer = observerSignal() + const defaultedOptions = defaultedOptionsSignal() - effect( - () => { - const defaultedOptions = defaultedOptionsSignal() + untracked(() => { observer.setOptions(defaultedOptions, { // Do not notify on updates because of changes in the options because // these changes should already be reflected in the optimistic result. listeners: false, }) - untracked(() => { - resultSignal.set(observer.getOptimisticResult(defaultedOptions)) - }) - }, - { - injector, - }, - ) + }) + onCleanup(() => { + resultFromSubscriberSignal.set(null) + }) + }, + { + injector, + }, + ) + effect(() => { // observer.trackResult is not used as this optimization is not needed for Angular - const unsubscribe = ngZone.runOutsideAngular(() => - observer.subscribe( - notifyManager.batchCalls( - (state: QueryObserverResult) => { + const observer = observerSignal() + + untracked(() => { + const unsubscribe = ngZone.runOutsideAngular(() => + observer.subscribe( + notifyManager.batchCalls((state) => { ngZone.run(() => { if ( state.isError && @@ -104,14 +120,20 @@ export function createBaseQuery< ) { throw state.error } - resultSignal.set(state) + resultFromSubscriberSignal.set(state) }) - }, + }), ), - ), - ) - destroyRef.onDestroy(unsubscribe) - - return signalProxy(resultSignal) + ) + destroyRef.onDestroy(unsubscribe) + }) }) + + return signalProxy( + computed(() => { + const subscriberResult = resultFromSubscriberSignal() + const optimisticResult = optimisticResultSignal() + return subscriberResult ?? optimisticResult + }), + ) } diff --git a/packages/angular-query-experimental/src/inject-mutation-state.ts b/packages/angular-query-experimental/src/inject-mutation-state.ts index e3a019a0aa..9bd5c61cdc 100644 --- a/packages/angular-query-experimental/src/inject-mutation-state.ts +++ b/packages/angular-query-experimental/src/inject-mutation-state.ts @@ -1,18 +1,10 @@ -import { - DestroyRef, - NgZone, - effect, - inject, - signal, - untracked, -} from '@angular/core' +import { DestroyRef, NgZone, computed, inject, signal } from '@angular/core' import { QueryClient, notifyManager, replaceEqualDeep, } from '@tanstack/query-core' import { assertInjector } from './util/assert-injector/assert-injector' -import { lazySignalInitializer } from './util/lazy-signal-initializer/lazy-signal-initializer' import type { Injector, Signal } from '@angular/core' import type { Mutation, @@ -63,42 +55,55 @@ export function injectMutationState( const mutationCache = queryClient.getMutationCache() - return lazySignalInitializer((injector) => { - const result = signal>( + /** + * Computed signal that gets result from mutation cache based on passed options + * First element is the result, second element is the time when the result was set + */ + const resultFromOptionsSignal = computed(() => { + return [ getResult(mutationCache, mutationStateOptionsFn()), - ) + performance.now(), + ] as const + }) - effect( - () => { - const mutationStateOptions = mutationStateOptionsFn() - untracked(() => { - // Setting the signal from an effect because it's both 'computed' from options() - // and needs to be set imperatively in the mutationCache listener. - result.set(getResult(mutationCache, mutationStateOptions)) - }) - }, - { injector }, - ) + /** + * Signal that contains result set by subscriber + * First element is the result, second element is the time when the result was set + */ + const resultFromSubscriberSignal = signal<[Array, number] | null>( + null, + ) - const unsubscribe = ngZone.runOutsideAngular(() => - mutationCache.subscribe( - notifyManager.batchCalls(() => { - const nextResult = replaceEqualDeep( - result(), - getResult(mutationCache, mutationStateOptionsFn()), - ) - if (result() !== nextResult) { - ngZone.run(() => { - result.set(nextResult) - }) - } - }), - ), - ) + /** + * Returns the last result by either subscriber or options + */ + const effectiveResultSignal = computed(() => { + const optionsResult = resultFromOptionsSignal() + const subscriberResult = resultFromSubscriberSignal() + return subscriberResult && subscriberResult[1] > optionsResult[1] + ? subscriberResult[0] + : optionsResult[0] + }) - destroyRef.onDestroy(unsubscribe) + const unsubscribe = ngZone.runOutsideAngular(() => + mutationCache.subscribe( + notifyManager.batchCalls(() => { + const [lastResult] = effectiveResultSignal() + const nextResult = replaceEqualDeep( + lastResult, + getResult(mutationCache, mutationStateOptionsFn()), + ) + if (lastResult !== nextResult) { + ngZone.run(() => { + resultFromSubscriberSignal.set([nextResult, performance.now()]) + }) + } + }), + ), + ) - return result - }) + destroyRef.onDestroy(unsubscribe) + + return effectiveResultSignal }) } diff --git a/packages/angular-query-experimental/src/inject-mutation.ts b/packages/angular-query-experimental/src/inject-mutation.ts index 7112c2ad3e..cf6b86f384 100644 --- a/packages/angular-query-experimental/src/inject-mutation.ts +++ b/packages/angular-query-experimental/src/inject-mutation.ts @@ -7,6 +7,7 @@ import { inject, runInInjectionContext, signal, + untracked, } from '@angular/core' import { MutationObserver, @@ -16,8 +17,6 @@ import { import { assertInjector } from './util/assert-injector/assert-injector' import { signalProxy } from './signal-proxy' import { noop, shouldThrowError } from './util' - -import { lazyInit } from './util/lazy-init/lazy-init' import type { DefaultError, MutationObserverResult } from '@tanstack/query-core' import type { CreateMutateFunction, CreateMutationResult } from './types' import type { CreateMutationOptions } from './mutation-options' @@ -46,42 +45,78 @@ export function injectMutation< const ngZone = inject(NgZone) const queryClient = inject(QueryClient) - return lazyInit(() => - runInInjectionContext(currentInjector, () => { - const observer = new MutationObserver< - TData, - TError, - TVariables, - TContext - >(queryClient, optionsFn()) - const mutate: CreateMutateFunction< - TData, - TError, - TVariables, - TContext - > = (variables, mutateOptions) => { - observer.mutate(variables, mutateOptions).catch(noop) - } - - effect(() => { - observer.setOptions( - runInInjectionContext(currentInjector, () => optionsFn()), - ) + /** + * computed() is used so signals can be inserted into the options + * making it reactive. Wrapping options in a function ensures embedded expressions + * are preserved and can keep being applied after signal changes + */ + const optionsSignal = computed(() => + runInInjectionContext(currentInjector, () => optionsFn()), + ) + + const observerSignal = (() => { + let instance: MutationObserver< + TData, + TError, + TVariables, + TContext + > | null = null + + return computed(() => { + return (instance ||= new MutationObserver(queryClient, optionsSignal())) + }) + })() + + const mutateFnSignal = computed< + CreateMutateFunction + >(() => { + const observer = observerSignal() + return (variables, mutateOptions) => { + observer.mutate(variables, mutateOptions).catch(noop) + } + }) + + /** + * Computed signal that gets result from mutation cache based on passed options + */ + const resultFromInitialOptionsSignal = computed(() => { + const observer = observerSignal() + return observer.getCurrentResult() + }) + + /** + * Signal that contains result set by subscriber + */ + const resultFromSubscriberSignal = signal | null>(null) + + effect( + () => { + const observer = observerSignal() + const options = optionsSignal() + + untracked(() => { + observer.setOptions(options) }) + }, + { + injector, + }, + ) + + effect( + () => { + // observer.trackResult is not used as this optimization is not needed for Angular + const observer = observerSignal() - const result = signal(observer.getCurrentResult()) - - const unsubscribe = ngZone.runOutsideAngular(() => - observer.subscribe( - notifyManager.batchCalls( - ( - state: MutationObserverResult< - TData, - TError, - TVariables, - TContext - >, - ) => { + untracked(() => { + const unsubscribe = ngZone.runOutsideAngular(() => + observer.subscribe( + notifyManager.batchCalls((state) => { ngZone.run(() => { if ( state.isError && @@ -91,28 +126,38 @@ export function injectMutation< ) { throw state.error } - result.set(state) + + resultFromSubscriberSignal.set(state) }) - }, + }), ), - ), - ) - - destroyRef.onDestroy(unsubscribe) - - const resultSignal = computed(() => ({ - ...result(), - mutate, - mutateAsync: result().mutate, - })) - - return signalProxy(resultSignal) as unknown as CreateMutationResult< - TData, - TError, - TVariables, - TContext - > - }), + ) + destroyRef.onDestroy(unsubscribe) + }) + }, + { + injector, + }, ) + + const resultSignal = computed(() => { + const resultFromSubscriber = resultFromSubscriberSignal() + const resultFromInitialOptions = resultFromInitialOptionsSignal() + + const result = resultFromSubscriber ?? resultFromInitialOptions + + return { + ...result, + mutate: mutateFnSignal(), + mutateAsync: result.mutate, + } + }) + + return signalProxy(resultSignal) as CreateMutationResult< + TData, + TError, + TVariables, + TContext + > }) } diff --git a/packages/angular-query-experimental/src/providers.ts b/packages/angular-query-experimental/src/providers.ts index cd340757df..42645b1b85 100644 --- a/packages/angular-query-experimental/src/providers.ts +++ b/packages/angular-query-experimental/src/providers.ts @@ -99,6 +99,7 @@ export function provideTanStackQuery( return makeEnvironmentProviders([ provideQueryClient(queryClient), { + // Do not use provideEnvironmentInitializer to support Angular < v19 provide: ENVIRONMENT_INITIALIZER, multi: true, useValue: () => { diff --git a/packages/angular-query-experimental/src/util/lazy-init/lazy-init.ts b/packages/angular-query-experimental/src/util/lazy-init/lazy-init.ts deleted file mode 100644 index 16c58429c3..0000000000 --- a/packages/angular-query-experimental/src/util/lazy-init/lazy-init.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { untracked } from '@angular/core' - -export function lazyInit(initializer: () => T): T { - let object: T | null = null - - const initializeObject = () => { - if (!object) { - object = untracked(() => initializer()) - } - } - - queueMicrotask(() => initializeObject()) - - return new Proxy({} as T, { - get(_, prop, receiver) { - initializeObject() - return Reflect.get(object as T, prop, receiver) - }, - has(_, prop) { - initializeObject() - return Reflect.has(object as T, prop) - }, - ownKeys() { - initializeObject() - return Reflect.ownKeys(object as T) - }, - getOwnPropertyDescriptor() { - return { - enumerable: true, - configurable: true, - } - }, - }) -} diff --git a/packages/angular-query-experimental/src/util/lazy-signal-initializer/lazy-signal-initializer.ts b/packages/angular-query-experimental/src/util/lazy-signal-initializer/lazy-signal-initializer.ts deleted file mode 100644 index 64cf16839f..0000000000 --- a/packages/angular-query-experimental/src/util/lazy-signal-initializer/lazy-signal-initializer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Injector, computed, inject, untracked } from '@angular/core' -import type { Signal } from '@angular/core' - -type SignalInitializerFn = (injector: Injector) => Signal - -export function lazySignalInitializer( - initializerFn: SignalInitializerFn, -) { - const injector = inject(Injector) - - let source: Signal | null = null - - const unwrapSignal = () => { - if (!source) { - source = untracked(() => initializerFn(injector)) - } - return source() - } - - queueMicrotask(() => unwrapSignal()) - - return computed(unwrapSignal) -}