diff --git a/.gitignore b/.gitignore index 16202c3520..ccd52cd34d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ stats.html .pnpm-store .svelte-kit .tsup +.vinxi vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/docs/framework/angular/guides/optimistic-updates.md b/docs/framework/angular/guides/optimistic-updates.md index 0f148e76bc..4786759124 100644 --- a/docs/framework/angular/guides/optimistic-updates.md +++ b/docs/framework/angular/guides/optimistic-updates.md @@ -72,7 +72,6 @@ addTodo = injectMutation(() => ({ // access variables somewhere else -// Note: injectMutationState is not available yet in Angular Query mutationState = injectMutationState(() => ({ filters: { mutationKey: ['addTodo'], status: 'pending' }, select: (mutation) => mutation.state.variables, diff --git a/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test-d.ts b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test-d.ts new file mode 100644 index 0000000000..463c06b53b --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test-d.ts @@ -0,0 +1,22 @@ +import { describe, expectTypeOf } from 'vitest' +import { injectMutationState } from '../inject-mutation-state' +import type { MutationState, MutationStatus } from '@tanstack/query-core' + +describe('injectMutationState', () => { + it('should default to QueryState', () => { + const result = injectMutationState(() => ({ + filters: { status: 'pending' }, + })) + + expectTypeOf(result()).toEqualTypeOf>() + }) + + it('should infer with select', () => { + const result = injectMutationState(() => ({ + filters: { status: 'pending' }, + select: (mutation) => mutation.state.status, + })) + + expectTypeOf(result()).toEqualTypeOf>() + }) +}) 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 new file mode 100644 index 0000000000..2e6a86bc31 --- /dev/null +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation-state.test.ts @@ -0,0 +1,108 @@ +import { signal } from '@angular/core' +import { QueryClient } from '@tanstack/query-core' +import { TestBed } from '@angular/core/testing' +import { describe, expect, test, vi } from 'vitest' +import { injectMutation } from '../inject-mutation' +import { injectMutationState } from '../inject-mutation-state' +import { provideAngularQuery } from '../providers' +import { successMutator } from './test-utils' + +describe('injectMutationState', () => { + let queryClient: QueryClient + + beforeEach(() => { + queryClient = new QueryClient() + vi.useFakeTimers() + TestBed.configureTestingModule({ + providers: [provideAngularQuery(queryClient)], + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('injectMutationState', () => { + test('should return variables after calling mutate', async () => { + const mutationKey = ['mutation'] + const variables = 'foo123' + + const mutation = TestBed.runInInjectionContext(() => { + return injectMutation(() => ({ + mutationKey: mutationKey, + mutationFn: (params: string) => successMutator(params), + })) + }) + + mutation.mutate(variables) + + const mutationState = TestBed.runInInjectionContext(() => { + return injectMutationState(() => ({ + filters: { mutationKey, status: 'pending' }, + select: (m) => m.state.variables, + })) + }) + + expect(mutationState()).toEqual([variables]) + }) + + test('reactive options should update injectMutationState', async () => { + const mutationKey1 = ['mutation1'] + const mutationKey2 = ['mutation2'] + const variables1 = 'foo123' + const variables2 = 'bar234' + + const [mutation1, mutation2] = TestBed.runInInjectionContext(() => { + return [ + injectMutation(() => ({ + mutationKey: mutationKey1, + mutationFn: (params: string) => successMutator(params), + })), + injectMutation(() => ({ + mutationKey: mutationKey2, + mutationFn: (params: string) => successMutator(params), + })), + ] + }) + + mutation1.mutate(variables1) + mutation2.mutate(variables2) + + const filterKey = signal(mutationKey1) + + const mutationState = TestBed.runInInjectionContext(() => { + return injectMutationState(() => ({ + filters: { mutationKey: filterKey(), status: 'pending' }, + select: (m) => m.state.variables, + })) + }) + + expect(mutationState()).toEqual([variables1]) + + filterKey.set(mutationKey2) + TestBed.flushEffects() + expect(mutationState()).toEqual([variables2]) + }) + + test('should return variables after calling mutate', async () => { + queryClient.clear() + const mutationKey = ['mutation'] + const variables = 'bar234' + + const mutation = TestBed.runInInjectionContext(() => { + return injectMutation(() => ({ + mutationKey: mutationKey, + mutationFn: (params: string) => successMutator(params), + })) + }) + + mutation.mutate(variables) + + const mutationState = TestBed.runInInjectionContext(() => { + return injectMutationState() + }) + + expect(mutationState()[0]?.variables).toEqual(variables) + }) + }) +}) 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 2d538bb209..259e1caf2e 100644 --- a/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts +++ b/packages/angular-query-experimental/src/__tests__/inject-mutation.test.ts @@ -123,7 +123,9 @@ describe('injectMutation', () => { test('reactive options should update mutation', async () => { const mutationCache = queryClient.getMutationCache() - const mutationKey = signal(['foo']) + // Signal will be updated before the mutation is called + // this test confirms that the mutation uses the updated value + const mutationKey = signal(['1']) const mutation = TestBed.runInInjectionContext(() => { return injectMutation(() => ({ mutationKey: mutationKey(), @@ -131,19 +133,15 @@ describe('injectMutation', () => { })) }) - mutationKey.set(['bar']) + mutationKey.set(['2']) TestBed.flushEffects() - await resolveMutations() - mutation.mutate('xyz') - await resolveMutations() - - const mutations = mutationCache.find({ mutationKey: ['bar'] }) + const mutations = mutationCache.find({ mutationKey: ['2'] }) - expect(mutations?.options.mutationKey).toEqual(['bar']) + expect(mutations?.options.mutationKey).toEqual(['2']) }) test('should reset state after invoking mutation.reset', async () => { diff --git a/packages/angular-query-experimental/src/create-base-query.ts b/packages/angular-query-experimental/src/create-base-query.ts index c5a0e11839..3fbde300f2 100644 --- a/packages/angular-query-experimental/src/create-base-query.ts +++ b/packages/angular-query-experimental/src/create-base-query.ts @@ -5,6 +5,7 @@ import { effect, inject, signal, + untracked, } from '@angular/core' import { notifyManager } from '@tanstack/query-core' import { signalProxy } from './signal-proxy' @@ -62,18 +63,19 @@ export function createBaseQuery< observer.getOptimisticResult(defaultedOptionsSignal()), ) - effect( - () => { + effect(() => { + const defaultedOptions = defaultedOptionsSignal() + 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. - const defaultedOptions = defaultedOptionsSignal() - observer.setOptions(defaultedOptions, { - listeners: false, - }) - resultSignal.set(observer.getOptimisticResult(defaultedOptions)) - }, - { allowSignalWrites: true }, - ) + listeners: false, + }) + untracked(() => + // Set the signal in effect because it's both 'computed' from options() + // and needs to be set imperatively in the query observer listener. + resultSignal.set(observer.getOptimisticResult(defaultedOptions)), + ) + }) // observer.trackResult is not used as this optimization is not needed for Angular const unsubscribe = observer.subscribe( diff --git a/packages/angular-query-experimental/src/index.ts b/packages/angular-query-experimental/src/index.ts index 89587b496d..d56be2adba 100644 --- a/packages/angular-query-experimental/src/index.ts +++ b/packages/angular-query-experimental/src/index.ts @@ -17,6 +17,7 @@ export * from './inject-infinite-query' export * from './inject-is-fetching' export * from './inject-is-mutating' export * from './inject-mutation' +export * from './inject-mutation-state' export * from './inject-queries' export * from './inject-query' export { diff --git a/packages/angular-query-experimental/src/inject-mutation-state.ts b/packages/angular-query-experimental/src/inject-mutation-state.ts new file mode 100644 index 0000000000..94fd80458f --- /dev/null +++ b/packages/angular-query-experimental/src/inject-mutation-state.ts @@ -0,0 +1,81 @@ +import { DestroyRef, effect, inject, signal, untracked } from '@angular/core' +import { + type DefaultError, + type Mutation, + type MutationCache, + type MutationFilters, + type MutationState, + notifyManager, + replaceEqualDeep, +} from '@tanstack/query-core' +import { assertInjector } from './util/assert-injector/assert-injector' +import { injectQueryClient } from './inject-query-client' +import type { Injector, Signal } from '@angular/core' + +type MutationStateOptions = { + filters?: MutationFilters + select?: ( + mutation: Mutation, + ) => TResult +} + +function getResult( + mutationCache: MutationCache, + options: MutationStateOptions, +): Array { + return mutationCache + .findAll(options.filters) + .map( + (mutation): TResult => + (options.select + ? options.select( + mutation as Mutation, + ) + : mutation.state) as TResult, + ) +} + +export interface InjectMutationStateOptions { + injector?: Injector +} + +export function injectMutationState( + mutationStateOptionsFn: () => MutationStateOptions = () => ({}), + options?: InjectMutationStateOptions, +): Signal> { + return assertInjector(injectMutationState, options?.injector, () => { + const destroyRef = inject(DestroyRef) + const queryClient = injectQueryClient() + + const mutationCache = queryClient.getMutationCache() + + const result = signal>( + getResult(mutationCache, mutationStateOptionsFn()), + ) + + 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)) + }) + }) + + const unsubscribe = mutationCache.subscribe( + notifyManager.batchCalls(() => { + const nextResult = replaceEqualDeep( + result(), + getResult(mutationCache, mutationStateOptionsFn()), + ) + if (result() !== nextResult) { + result.set(nextResult) + } + }), + ) + + destroyRef.onDestroy(unsubscribe) + + return result + }) +}