@tanstack/angular-query-experimental 5.103.2 → 5.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (218) hide show
  1. package/dist/create-base-query.d.mts +168 -0
  2. package/dist/create-base-query.d.mts.map +1 -0
  3. package/dist/create-base-query.mjs +91 -0
  4. package/dist/create-base-query.mjs.map +1 -0
  5. package/dist/devtools/index.d.mts +3 -0
  6. package/dist/devtools/index.mjs +2 -0
  7. package/dist/devtools/production/index.d.mts +4 -0
  8. package/dist/devtools/production/index.mjs +2 -0
  9. package/dist/devtools/stub.d.mts +5 -0
  10. package/dist/devtools/stub.d.mts.map +1 -0
  11. package/dist/devtools/stub.mjs +9 -0
  12. package/dist/devtools/stub.mjs.map +1 -0
  13. package/dist/devtools/types.d.mts +106 -0
  14. package/dist/devtools/types.d.mts.map +1 -0
  15. package/dist/devtools/types.mjs +1 -0
  16. package/{devtools/with-devtools.d.ts → dist/devtools/with-devtools.d.mts} +4 -1
  17. package/dist/devtools/with-devtools.d.mts.map +1 -0
  18. package/dist/devtools/with-devtools.mjs +110 -0
  19. package/dist/devtools/with-devtools.mjs.map +1 -0
  20. package/dist/devtools-panel/index.d.mts +3 -0
  21. package/dist/devtools-panel/index.mjs +2 -0
  22. package/{devtools-panel/inject-devtools-panel.d.ts → dist/devtools-panel/inject-devtools-panel.d.mts} +4 -1
  23. package/dist/devtools-panel/inject-devtools-panel.d.mts.map +1 -0
  24. package/dist/devtools-panel/inject-devtools-panel.mjs +67 -0
  25. package/dist/devtools-panel/inject-devtools-panel.mjs.map +1 -0
  26. package/dist/devtools-panel/production/index.d.mts +4 -0
  27. package/dist/devtools-panel/production/index.mjs +2 -0
  28. package/dist/devtools-panel/stub.d.mts +5 -0
  29. package/dist/devtools-panel/stub.d.mts.map +1 -0
  30. package/dist/devtools-panel/stub.mjs +7 -0
  31. package/dist/devtools-panel/stub.mjs.map +1 -0
  32. package/dist/devtools-panel/types.d.mts +51 -0
  33. package/dist/devtools-panel/types.d.mts.map +1 -0
  34. package/dist/devtools-panel/types.mjs +1 -0
  35. package/dist/index.d.mts +16 -0
  36. package/{index.mjs → dist/index.mjs} +8 -21
  37. package/dist/index.mjs.map +1 -0
  38. package/{infinite-query-options.d.ts → dist/infinite-query-options.d.mts} +28 -25
  39. package/dist/infinite-query-options.d.mts.map +1 -0
  40. package/dist/infinite-query-options.mjs +8 -0
  41. package/dist/infinite-query-options.mjs.map +1 -0
  42. package/{inject-infinite-query.d.ts → dist/inject-infinite-query.d.mts} +13 -10
  43. package/dist/inject-infinite-query.d.mts.map +1 -0
  44. package/dist/inject-infinite-query.mjs +13 -0
  45. package/dist/inject-infinite-query.mjs.map +1 -0
  46. package/{inject-is-fetching.d.ts → dist/inject-is-fetching.d.mts} +11 -8
  47. package/dist/inject-is-fetching.d.mts.map +1 -0
  48. package/dist/inject-is-fetching.mjs +69 -0
  49. package/dist/inject-is-fetching.mjs.map +1 -0
  50. package/{inject-is-mutating.d.ts → dist/inject-is-mutating.d.mts} +11 -8
  51. package/dist/inject-is-mutating.d.mts.map +1 -0
  52. package/dist/inject-is-mutating.mjs +52 -0
  53. package/dist/inject-is-mutating.mjs.map +1 -0
  54. package/{inject-is-restoring.d.ts → dist/inject-is-restoring.d.mts} +10 -8
  55. package/dist/inject-is-restoring.d.mts.map +1 -0
  56. package/dist/inject-is-restoring.mjs +35 -0
  57. package/dist/inject-is-restoring.mjs.map +1 -0
  58. package/{inject-mutation-state.d.ts → dist/inject-mutation-state.d.mts} +13 -11
  59. package/dist/inject-mutation-state.d.mts.map +1 -0
  60. package/dist/inject-mutation-state.mjs +103 -0
  61. package/dist/inject-mutation-state.mjs.map +1 -0
  62. package/{inject-mutation.d.ts → dist/inject-mutation.d.mts} +12 -9
  63. package/dist/inject-mutation.d.mts.map +1 -0
  64. package/dist/inject-mutation.mjs +226 -0
  65. package/dist/inject-mutation.mjs.map +1 -0
  66. package/dist/inject-queries-experimental/index.d.mts +2 -0
  67. package/dist/inject-queries-experimental/index.mjs +2 -0
  68. package/{inject-queries.d.ts → dist/inject-queries.d.mts} +33 -53
  69. package/dist/inject-queries.d.mts.map +1 -0
  70. package/dist/inject-queries.mjs +203 -0
  71. package/dist/inject-queries.mjs.map +1 -0
  72. package/{inject-query-client.d.ts → dist/inject-query-client.d.mts} +6 -3
  73. package/dist/inject-query-client.d.mts.map +1 -0
  74. package/dist/inject-query-client.mjs +23 -0
  75. package/dist/inject-query-client.mjs.map +1 -0
  76. package/{inject-query.d.ts → dist/inject-query.d.mts} +13 -10
  77. package/dist/inject-query.d.mts.map +1 -0
  78. package/dist/inject-query.mjs +12 -0
  79. package/dist/inject-query.mjs.map +1 -0
  80. package/{mutation-options.d.ts → dist/mutation-options.d.mts} +5 -2
  81. package/dist/mutation-options.d.mts.map +1 -0
  82. package/dist/mutation-options.mjs +8 -0
  83. package/dist/mutation-options.mjs.map +1 -0
  84. package/dist/pending-tasks-compat.d.mts +9 -0
  85. package/dist/pending-tasks-compat.d.mts.map +1 -0
  86. package/dist/pending-tasks-compat.mjs +13 -0
  87. package/dist/pending-tasks-compat.mjs.map +1 -0
  88. package/{providers.d.ts → dist/providers.d.mts} +7 -5
  89. package/dist/providers.d.mts.map +1 -0
  90. package/dist/providers.mjs +124 -0
  91. package/dist/providers.mjs.map +1 -0
  92. package/{query-options.d.ts → dist/query-options.d.mts} +34 -31
  93. package/dist/query-options.d.mts.map +1 -0
  94. package/dist/query-options.mjs +8 -0
  95. package/dist/query-options.mjs.map +1 -0
  96. package/{signal-proxy.d.ts → dist/signal-proxy.d.mts} +5 -4
  97. package/dist/signal-proxy.d.mts.map +1 -0
  98. package/dist/signal-proxy.mjs +35 -0
  99. package/dist/signal-proxy.mjs.map +1 -0
  100. package/{types.d.ts → dist/types.d.mts} +24 -26
  101. package/dist/types.d.mts.map +1 -0
  102. package/dist/types.mjs +6 -0
  103. package/dist/types.mjs.map +1 -0
  104. package/package.json +50 -20
  105. package/src/__tests__/infinite-query-options.test-d.ts +314 -0
  106. package/src/__tests__/infinite-query-options.test.ts +19 -0
  107. package/src/__tests__/inject-devtools-panel.test.ts +196 -0
  108. package/src/__tests__/inject-infinite-query.test-d.ts +66 -0
  109. package/src/__tests__/inject-infinite-query.test.ts +227 -0
  110. package/src/__tests__/inject-is-fetching.test.ts +108 -0
  111. package/src/__tests__/inject-is-mutating.test.ts +113 -0
  112. package/src/__tests__/inject-is-restoring.test.ts +93 -0
  113. package/src/__tests__/inject-mutation-state.test-d.ts +22 -0
  114. package/src/__tests__/inject-mutation-state.test.ts +198 -0
  115. package/src/__tests__/inject-mutation.test-d.ts +158 -0
  116. package/src/__tests__/inject-mutation.test.ts +1161 -0
  117. package/src/__tests__/inject-queries.test-d.ts +196 -0
  118. package/src/__tests__/inject-queries.test.ts +240 -0
  119. package/src/__tests__/inject-query.test-d.ts +357 -0
  120. package/src/__tests__/inject-query.test.ts +700 -0
  121. package/src/__tests__/mutation-options.test-d.ts +234 -0
  122. package/src/__tests__/mutation-options.test.ts +344 -0
  123. package/src/__tests__/pending-tasks.test.ts +644 -0
  124. package/src/__tests__/provide-query-client.test.ts +38 -0
  125. package/src/__tests__/provide-tanstack-query.test.ts +38 -0
  126. package/src/__tests__/query-options.test-d.ts +262 -0
  127. package/src/__tests__/query-options.test.ts +14 -0
  128. package/src/__tests__/signal-proxy.test.ts +27 -0
  129. package/src/__tests__/test-utils.ts +77 -0
  130. package/src/__tests__/with-devtools.test.ts +674 -0
  131. package/src/create-base-query.ts +176 -0
  132. package/src/devtools/index.ts +8 -0
  133. package/src/devtools/production/index.ts +1 -0
  134. package/src/devtools/stub.ts +7 -0
  135. package/src/devtools/types.ts +116 -0
  136. package/src/devtools/with-devtools.ts +182 -0
  137. package/src/devtools-panel/index.ts +8 -0
  138. package/src/devtools-panel/inject-devtools-panel.ts +110 -0
  139. package/src/devtools-panel/production/index.ts +1 -0
  140. package/src/devtools-panel/stub.ts +7 -0
  141. package/src/devtools-panel/types.ts +57 -0
  142. package/src/index.ts +60 -0
  143. package/src/infinite-query-options.ts +354 -0
  144. package/src/inject-infinite-query.ts +296 -0
  145. package/src/inject-is-fetching.ts +97 -0
  146. package/src/inject-is-mutating.ts +80 -0
  147. package/src/inject-is-restoring.ts +53 -0
  148. package/src/inject-mutation-state.ts +167 -0
  149. package/src/inject-mutation.ts +328 -0
  150. package/src/inject-queries-experimental/index.ts +1 -0
  151. package/src/inject-queries.ts +480 -0
  152. package/src/inject-query-client.ts +22 -0
  153. package/src/inject-query.ts +208 -0
  154. package/src/mutation-options.ts +121 -0
  155. package/src/pending-tasks-compat.ts +27 -0
  156. package/src/providers.ts +170 -0
  157. package/src/query-options.ts +292 -0
  158. package/src/signal-proxy.ts +46 -0
  159. package/src/types.ts +429 -0
  160. package/create-base-query.d.ts +0 -164
  161. package/create-base-query.mjs +0 -99
  162. package/create-base-query.mjs.map +0 -1
  163. package/devtools/index.d.ts +0 -2
  164. package/devtools/index.mjs +0 -5
  165. package/devtools/index.mjs.map +0 -1
  166. package/devtools/production/index.d.ts +0 -1
  167. package/devtools/stub.d.ts +0 -2
  168. package/devtools/stub.mjs +0 -8
  169. package/devtools/stub.mjs.map +0 -1
  170. package/devtools/types.d.ts +0 -103
  171. package/devtools/with-devtools.mjs +0 -106
  172. package/devtools/with-devtools.mjs.map +0 -1
  173. package/devtools-panel/index.d.ts +0 -2
  174. package/devtools-panel/index.mjs +0 -5
  175. package/devtools-panel/index.mjs.map +0 -1
  176. package/devtools-panel/inject-devtools-panel.mjs +0 -72
  177. package/devtools-panel/inject-devtools-panel.mjs.map +0 -1
  178. package/devtools-panel/production/index.d.ts +0 -1
  179. package/devtools-panel/stub.d.ts +0 -2
  180. package/devtools-panel/stub.mjs +0 -8
  181. package/devtools-panel/stub.mjs.map +0 -1
  182. package/devtools-panel/types.d.ts +0 -48
  183. package/index.d.ts +0 -25
  184. package/index.mjs.map +0 -1
  185. package/infinite-query-options.mjs +0 -7
  186. package/infinite-query-options.mjs.map +0 -1
  187. package/inject-infinite-query.mjs +0 -18
  188. package/inject-infinite-query.mjs.map +0 -1
  189. package/inject-is-fetching.mjs +0 -31
  190. package/inject-is-fetching.mjs.map +0 -1
  191. package/inject-is-mutating.mjs +0 -31
  192. package/inject-is-mutating.mjs.map +0 -1
  193. package/inject-is-restoring.mjs +0 -21
  194. package/inject-is-restoring.mjs.map +0 -1
  195. package/inject-mutation-state.mjs +0 -51
  196. package/inject-mutation-state.mjs.map +0 -1
  197. package/inject-mutation.mjs +0 -94
  198. package/inject-mutation.mjs.map +0 -1
  199. package/inject-queries-experimental/index.d.ts +0 -1
  200. package/inject-queries-experimental/index.mjs +0 -5
  201. package/inject-queries-experimental/index.mjs.map +0 -1
  202. package/inject-queries.mjs +0 -82
  203. package/inject-queries.mjs.map +0 -1
  204. package/inject-query-client.mjs +0 -9
  205. package/inject-query-client.mjs.map +0 -1
  206. package/inject-query.mjs +0 -14
  207. package/inject-query.mjs.map +0 -1
  208. package/mutation-options.mjs +0 -7
  209. package/mutation-options.mjs.map +0 -1
  210. package/pending-tasks-compat.d.ts +0 -7
  211. package/pending-tasks-compat.mjs +0 -19
  212. package/pending-tasks-compat.mjs.map +0 -1
  213. package/providers.mjs +0 -32
  214. package/providers.mjs.map +0 -1
  215. package/query-options.mjs +0 -7
  216. package/query-options.mjs.map +0 -1
  217. package/signal-proxy.mjs +0 -29
  218. package/signal-proxy.mjs.map +0 -1
@@ -0,0 +1,1161 @@
1
+ import {
2
+ ApplicationRef,
3
+ Component,
4
+ Injector,
5
+ input,
6
+ provideZonelessChangeDetection,
7
+ signal,
8
+ } from '@angular/core'
9
+ import { TestBed } from '@angular/core/testing'
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+ import { By } from '@angular/platform-browser'
12
+ import { render } from '@testing-library/angular'
13
+ import { queryKey, sleep } from '@tanstack/query-test-utils'
14
+ import { noop } from '@tanstack/query-core'
15
+ import { QueryClient, injectMutation, provideTanStackQuery } from '..'
16
+ import { expectSignals, setFixtureSignalInputs } from './test-utils'
17
+
18
+ describe('injectMutation', () => {
19
+ let queryClient: QueryClient
20
+
21
+ beforeEach(() => {
22
+ queryClient = new QueryClient()
23
+ vi.useFakeTimers()
24
+ TestBed.configureTestingModule({
25
+ providers: [
26
+ provideZonelessChangeDetection(),
27
+ provideTanStackQuery(queryClient),
28
+ ],
29
+ })
30
+ })
31
+
32
+ afterEach(() => {
33
+ vi.useRealTimers()
34
+ })
35
+
36
+ it('should be in idle state initially', () => {
37
+ const mutation = TestBed.runInInjectionContext(() => {
38
+ return injectMutation(() => ({
39
+ mutationFn: (params) => sleep(0).then(() => params),
40
+ }))
41
+ })
42
+
43
+ expectSignals(mutation, {
44
+ isIdle: true,
45
+ isPending: false,
46
+ isError: false,
47
+ isSuccess: false,
48
+ })
49
+ })
50
+
51
+ it('should change state after invoking mutate', async () => {
52
+ const result = 'Mock data'
53
+
54
+ @Component({
55
+ template: `
56
+ <div>isIdle: {{ mutation.isIdle() }}</div>
57
+ <div>isPending: {{ mutation.isPending() }}</div>
58
+ <div>isError: {{ mutation.isError() }}</div>
59
+ <div>isSuccess: {{ mutation.isSuccess() }}</div>
60
+ <div>data: {{ mutation.data() ?? 'none' }}</div>
61
+ <div>error: {{ mutation.error()?.message ?? 'none' }}</div>
62
+ `,
63
+ })
64
+ class Page {
65
+ readonly mutation = injectMutation(() => ({
66
+ mutationFn: (params: string) => sleep(10).then(() => params),
67
+ }))
68
+ }
69
+
70
+ const rendered = await render(Page)
71
+
72
+ rendered.fixture.componentInstance.mutation.mutate(result)
73
+ await vi.advanceTimersByTimeAsync(0)
74
+ rendered.fixture.detectChanges()
75
+
76
+ expect(rendered.getByText('isIdle: false')).toBeInTheDocument()
77
+ expect(rendered.getByText('isPending: true')).toBeInTheDocument()
78
+ expect(rendered.getByText('isError: false')).toBeInTheDocument()
79
+ expect(rendered.getByText('isSuccess: false')).toBeInTheDocument()
80
+ expect(rendered.getByText('data: none')).toBeInTheDocument()
81
+ expect(rendered.getByText('error: none')).toBeInTheDocument()
82
+ })
83
+
84
+ it('should return error when request fails', async () => {
85
+ @Component({
86
+ template: `
87
+ <div>isIdle: {{ mutation.isIdle() }}</div>
88
+ <div>isPending: {{ mutation.isPending() }}</div>
89
+ <div>isError: {{ mutation.isError() }}</div>
90
+ <div>isSuccess: {{ mutation.isSuccess() }}</div>
91
+ <div>data: {{ mutation.data() ?? 'none' }}</div>
92
+ <div>error: {{ mutation.error()?.message ?? 'none' }}</div>
93
+ `,
94
+ })
95
+ class Page {
96
+ readonly mutation = injectMutation(() => ({
97
+ mutationFn: () =>
98
+ sleep(10).then(() => Promise.reject(new Error('Some error'))),
99
+ }))
100
+ }
101
+
102
+ const rendered = await render(Page)
103
+
104
+ rendered.fixture.componentInstance.mutation.mutate()
105
+
106
+ await vi.advanceTimersByTimeAsync(11)
107
+ rendered.fixture.detectChanges()
108
+
109
+ expect(rendered.getByText('isIdle: false')).toBeInTheDocument()
110
+ expect(rendered.getByText('isPending: false')).toBeInTheDocument()
111
+ expect(rendered.getByText('isError: true')).toBeInTheDocument()
112
+ expect(rendered.getByText('isSuccess: false')).toBeInTheDocument()
113
+ expect(rendered.getByText('data: none')).toBeInTheDocument()
114
+ expect(rendered.getByText('error: Some error')).toBeInTheDocument()
115
+ })
116
+
117
+ it('should return data when request succeeds', async () => {
118
+ const result = 'Mock data'
119
+
120
+ @Component({
121
+ template: `
122
+ <div>isIdle: {{ mutation.isIdle() }}</div>
123
+ <div>isPending: {{ mutation.isPending() }}</div>
124
+ <div>isError: {{ mutation.isError() }}</div>
125
+ <div>isSuccess: {{ mutation.isSuccess() }}</div>
126
+ <div>data: {{ mutation.data() ?? 'none' }}</div>
127
+ <div>error: {{ mutation.error()?.message ?? 'none' }}</div>
128
+ `,
129
+ })
130
+ class Page {
131
+ readonly mutation = injectMutation(() => ({
132
+ mutationFn: (params: string) => sleep(10).then(() => params),
133
+ }))
134
+ }
135
+
136
+ const rendered = await render(Page)
137
+
138
+ rendered.fixture.componentInstance.mutation.mutate(result)
139
+
140
+ await vi.advanceTimersByTimeAsync(11)
141
+ rendered.fixture.detectChanges()
142
+
143
+ expect(rendered.getByText('isIdle: false')).toBeInTheDocument()
144
+ expect(rendered.getByText('isPending: false')).toBeInTheDocument()
145
+ expect(rendered.getByText('isError: false')).toBeInTheDocument()
146
+ expect(rendered.getByText('isSuccess: true')).toBeInTheDocument()
147
+ expect(rendered.getByText(`data: ${result}`)).toBeInTheDocument()
148
+ expect(rendered.getByText('error: none')).toBeInTheDocument()
149
+ })
150
+
151
+ it('should update mutation when reactive options change', () => {
152
+ const mutationCache = queryClient.getMutationCache()
153
+ // Signal will be updated before the mutation is called
154
+ // this test confirms that the mutation uses the updated value
155
+ const key1 = queryKey()
156
+ const key2 = queryKey()
157
+ const mutationKey = signal(key1)
158
+ const mutation = TestBed.runInInjectionContext(() => {
159
+ return injectMutation(() => ({
160
+ mutationKey: mutationKey(),
161
+ mutationFn: (params: string) => sleep(0).then(() => params),
162
+ }))
163
+ })
164
+
165
+ mutationKey.set(key2)
166
+
167
+ mutation.mutate('xyz')
168
+
169
+ const mutations = mutationCache.find({ mutationKey: key2 })
170
+
171
+ expect(mutations?.options.mutationKey).toEqual(key2)
172
+ })
173
+
174
+ it('should reset state after invoking mutation.reset', async () => {
175
+ @Component({
176
+ template: `
177
+ <div>isIdle: {{ mutation.isIdle() }}</div>
178
+ <div>isPending: {{ mutation.isPending() }}</div>
179
+ <div>isError: {{ mutation.isError() }}</div>
180
+ <div>isSuccess: {{ mutation.isSuccess() }}</div>
181
+ <div>data: {{ mutation.data() ?? 'none' }}</div>
182
+ <div>error: {{ mutation.error()?.message ?? 'none' }}</div>
183
+ `,
184
+ })
185
+ class Page {
186
+ readonly mutation = injectMutation(() => ({
187
+ mutationFn: () =>
188
+ sleep(10).then(() => Promise.reject(new Error('Some error'))),
189
+ }))
190
+ }
191
+
192
+ const rendered = await render(Page)
193
+
194
+ rendered.fixture.componentInstance.mutation.mutate()
195
+
196
+ await vi.advanceTimersByTimeAsync(11)
197
+ rendered.fixture.detectChanges()
198
+
199
+ expect(rendered.getByText('isError: true')).toBeInTheDocument()
200
+ expect(rendered.getByText('error: Some error')).toBeInTheDocument()
201
+
202
+ rendered.fixture.componentInstance.mutation.reset()
203
+
204
+ await vi.advanceTimersByTimeAsync(0)
205
+ rendered.fixture.detectChanges()
206
+
207
+ expect(rendered.getByText('isIdle: true')).toBeInTheDocument()
208
+ expect(rendered.getByText('isPending: false')).toBeInTheDocument()
209
+ expect(rendered.getByText('isError: false')).toBeInTheDocument()
210
+ expect(rendered.getByText('isSuccess: false')).toBeInTheDocument()
211
+ expect(rendered.getByText('data: none')).toBeInTheDocument()
212
+ expect(rendered.getByText('error: none')).toBeInTheDocument()
213
+ })
214
+
215
+ describe('side effects', () => {
216
+ beforeEach(() => {
217
+ vi.clearAllMocks()
218
+ })
219
+
220
+ it('should call onMutate when passed as an option', async () => {
221
+ const onMutate = vi.fn()
222
+ const mutation = TestBed.runInInjectionContext(() => {
223
+ return injectMutation(() => ({
224
+ mutationFn: (params: string) => sleep(10).then(() => params),
225
+ onMutate,
226
+ }))
227
+ })
228
+
229
+ mutation.mutate('')
230
+
231
+ await vi.advanceTimersByTimeAsync(0)
232
+
233
+ expect(onMutate).toHaveBeenCalledTimes(1)
234
+ })
235
+
236
+ it('should call onError when passed as an option', async () => {
237
+ const onError = vi.fn()
238
+ const mutation = TestBed.runInInjectionContext(() => {
239
+ return injectMutation(() => ({
240
+ mutationFn: (_params: string) =>
241
+ sleep(10).then(() => Promise.reject(new Error('Some error'))),
242
+ onError,
243
+ }))
244
+ })
245
+
246
+ mutation.mutate('')
247
+
248
+ await vi.advanceTimersByTimeAsync(10)
249
+
250
+ expect(onError).toHaveBeenCalledTimes(1)
251
+ })
252
+
253
+ it('should call onSuccess when passed as an option', async () => {
254
+ const onSuccess = vi.fn()
255
+ const mutation = TestBed.runInInjectionContext(() => {
256
+ return injectMutation(() => ({
257
+ mutationFn: (params: string) => sleep(10).then(() => params),
258
+ onSuccess,
259
+ }))
260
+ })
261
+
262
+ mutation.mutate('')
263
+
264
+ await vi.advanceTimersByTimeAsync(10)
265
+
266
+ expect(onSuccess).toHaveBeenCalledTimes(1)
267
+ })
268
+
269
+ it('should call onSettled when passed as an option', async () => {
270
+ const onSettled = vi.fn()
271
+ const mutation = TestBed.runInInjectionContext(() => {
272
+ return injectMutation(() => ({
273
+ mutationFn: (params: string) => sleep(10).then(() => params),
274
+ onSettled,
275
+ }))
276
+ })
277
+
278
+ mutation.mutate('')
279
+
280
+ await vi.advanceTimersByTimeAsync(10)
281
+
282
+ expect(onSettled).toHaveBeenCalledTimes(1)
283
+ })
284
+
285
+ it('should call onError when passed as an argument of mutate function', async () => {
286
+ const onError = vi.fn()
287
+ const mutation = TestBed.runInInjectionContext(() => {
288
+ return injectMutation(() => ({
289
+ mutationFn: (_params: string) =>
290
+ sleep(10).then(() => Promise.reject(new Error('Some error'))),
291
+ }))
292
+ })
293
+
294
+ mutation.mutate('', { onError })
295
+
296
+ await vi.advanceTimersByTimeAsync(10)
297
+
298
+ expect(onError).toHaveBeenCalledTimes(1)
299
+ })
300
+
301
+ it('should call onSuccess when passed as an argument of mutate function', async () => {
302
+ const onSuccess = vi.fn()
303
+ const mutation = TestBed.runInInjectionContext(() => {
304
+ return injectMutation(() => ({
305
+ mutationFn: (params: string) => sleep(10).then(() => params),
306
+ }))
307
+ })
308
+
309
+ mutation.mutate('', { onSuccess })
310
+
311
+ await vi.advanceTimersByTimeAsync(10)
312
+
313
+ expect(onSuccess).toHaveBeenCalledTimes(1)
314
+ })
315
+
316
+ it('should call onSettled when passed as an argument of mutate function', async () => {
317
+ const onSettled = vi.fn()
318
+ const mutation = TestBed.runInInjectionContext(() => {
319
+ return injectMutation(() => ({
320
+ mutationFn: (params: string) => sleep(10).then(() => params),
321
+ }))
322
+ })
323
+
324
+ mutation.mutate('', { onSettled })
325
+
326
+ await vi.advanceTimersByTimeAsync(10)
327
+
328
+ expect(onSettled).toHaveBeenCalledTimes(1)
329
+ })
330
+
331
+ it('should call onSuccess and onSettled when passed as arguments of mutate function', async () => {
332
+ const callbacks: Array<string> = []
333
+ const mutation = TestBed.runInInjectionContext(() => {
334
+ return injectMutation(() => ({
335
+ mutationFn: (params: string) => sleep(10).then(() => params),
336
+ }))
337
+ })
338
+
339
+ mutation.mutate('', {
340
+ onSuccess: () => callbacks.push('mutate.onSuccess'),
341
+ onSettled: () => callbacks.push('mutate.onSettled'),
342
+ })
343
+
344
+ await vi.advanceTimersByTimeAsync(10)
345
+
346
+ expect(callbacks).toEqual(['mutate.onSuccess', 'mutate.onSettled'])
347
+ })
348
+
349
+ it('should call onError and onSettled when passed as arguments of mutate function', async () => {
350
+ const callbacks: Array<string> = []
351
+ const mutation = TestBed.runInInjectionContext(() => {
352
+ return injectMutation(() => ({
353
+ mutationFn: (_params: string) =>
354
+ sleep(10).then(() => Promise.reject(new Error('Some error'))),
355
+ }))
356
+ })
357
+
358
+ mutation.mutate('', {
359
+ onError: () => callbacks.push('mutate.onError'),
360
+ onSettled: () => callbacks.push('mutate.onSettled'),
361
+ })
362
+
363
+ await vi.advanceTimersByTimeAsync(10)
364
+
365
+ expect(callbacks).toEqual(['mutate.onError', 'mutate.onSettled'])
366
+ })
367
+
368
+ it('should call onSuccess and onSettled when passed as arguments of mutateAsync function', async () => {
369
+ const callbacks: Array<string> = []
370
+ const mutation = TestBed.runInInjectionContext(() => {
371
+ return injectMutation(() => ({
372
+ mutationFn: (params: string) => sleep(10).then(() => params),
373
+ }))
374
+ })
375
+
376
+ mutation.mutateAsync('', {
377
+ onSuccess: () => callbacks.push('mutateAsync.onSuccess'),
378
+ onSettled: () => callbacks.push('mutateAsync.onSettled'),
379
+ })
380
+
381
+ await vi.advanceTimersByTimeAsync(10)
382
+
383
+ expect(callbacks).toEqual([
384
+ 'mutateAsync.onSuccess',
385
+ 'mutateAsync.onSettled',
386
+ ])
387
+ })
388
+
389
+ it('should call onError and onSettled when passed as arguments of mutateAsync function', async () => {
390
+ const callbacks: Array<string> = []
391
+ const mutation = TestBed.runInInjectionContext(() => {
392
+ return injectMutation(() => ({
393
+ mutationFn: (_params: string) =>
394
+ sleep(10).then(() => Promise.reject(new Error('Some error'))),
395
+ }))
396
+ })
397
+
398
+ mutation
399
+ .mutateAsync('', {
400
+ onError: () => callbacks.push('mutateAsync.onError'),
401
+ onSettled: () => callbacks.push('mutateAsync.onSettled'),
402
+ })
403
+ .catch(noop)
404
+
405
+ await vi.advanceTimersByTimeAsync(10)
406
+
407
+ expect(callbacks).toEqual([
408
+ 'mutateAsync.onError',
409
+ 'mutateAsync.onSettled',
410
+ ])
411
+ })
412
+
413
+ it('should call onSuccess when passed as an argument of mutateAsync function', async () => {
414
+ const callbacks: Array<string> = []
415
+ const mutation = TestBed.runInInjectionContext(() => {
416
+ return injectMutation(() => ({
417
+ mutationFn: (params: string) => sleep(10).then(() => params),
418
+ }))
419
+ })
420
+
421
+ mutation.mutateAsync('', {
422
+ onSuccess: () => callbacks.push('mutateAsync.onSuccess'),
423
+ })
424
+
425
+ await vi.advanceTimersByTimeAsync(10)
426
+
427
+ expect(callbacks).toEqual(['mutateAsync.onSuccess'])
428
+ })
429
+
430
+ it('should call onError when passed as an argument of mutateAsync function', async () => {
431
+ const callbacks: Array<string> = []
432
+ const mutation = TestBed.runInInjectionContext(() => {
433
+ return injectMutation(() => ({
434
+ mutationFn: (_params: string) =>
435
+ sleep(10).then(() => Promise.reject(new Error('Some error'))),
436
+ }))
437
+ })
438
+
439
+ mutation
440
+ .mutateAsync('', {
441
+ onError: () => callbacks.push('mutateAsync.onError'),
442
+ })
443
+ .catch(noop)
444
+
445
+ await vi.advanceTimersByTimeAsync(10)
446
+
447
+ expect(callbacks).toEqual(['mutateAsync.onError'])
448
+ })
449
+
450
+ it('should call onSettled when passed as an argument of mutateAsync function', async () => {
451
+ const callbacks: Array<string> = []
452
+ const mutation = TestBed.runInInjectionContext(() => {
453
+ return injectMutation(() => ({
454
+ mutationFn: (params: string) => sleep(10).then(() => params),
455
+ }))
456
+ })
457
+
458
+ mutation.mutateAsync('', {
459
+ onSettled: () => callbacks.push('mutateAsync.onSettled'),
460
+ })
461
+
462
+ await vi.advanceTimersByTimeAsync(10)
463
+
464
+ expect(callbacks).toEqual(['mutateAsync.onSettled'])
465
+ })
466
+
467
+ it('should fire both onSettled functions', async () => {
468
+ const onSettled = vi.fn()
469
+ const onSettledMutate = vi.fn()
470
+ const mutation = TestBed.runInInjectionContext(() => {
471
+ return injectMutation(() => ({
472
+ mutationFn: (params: string) => sleep(10).then(() => params),
473
+ onSettled,
474
+ }))
475
+ })
476
+
477
+ mutation.mutate('', { onSettled: onSettledMutate })
478
+
479
+ await vi.advanceTimersByTimeAsync(10)
480
+
481
+ expect(onSettled).toHaveBeenCalledTimes(1)
482
+ expect(onSettledMutate).toHaveBeenCalledTimes(1)
483
+ })
484
+
485
+ it('should pass a non-undefined onMutateResult alongside context to onSuccess', async () => {
486
+ const onSuccess = vi.fn()
487
+ const mutation = TestBed.runInInjectionContext(() => {
488
+ return injectMutation(() => ({
489
+ mutationFn: (text: string) =>
490
+ sleep(10).then(() => text.toUpperCase()),
491
+ onMutate: (text: string) => ({ startedWith: text }),
492
+ onSuccess,
493
+ }))
494
+ })
495
+
496
+ mutation.mutate('todo')
497
+
498
+ await vi.advanceTimersByTimeAsync(10)
499
+
500
+ expect(onSuccess).toHaveBeenCalledTimes(1)
501
+ const [data, variables, onMutateResult, context] =
502
+ onSuccess.mock.calls[0]!
503
+ expect(data).toBe('TODO')
504
+ expect(variables).toBe('todo')
505
+ expect(onMutateResult).toEqual({ startedWith: 'todo' })
506
+ expect(context.client).toBe(queryClient)
507
+ expect(context.meta).toBeUndefined()
508
+ expect(context.mutationKey).toBeUndefined()
509
+ })
510
+
511
+ it('should give mutationFn the same QueryClient instance via context', async () => {
512
+ const key = queryKey()
513
+ queryClient.setQueryData(key, 'tag-from-this-client')
514
+
515
+ @Component({
516
+ template: `<div>data: {{ mutation.data() ?? 'none' }}</div>`,
517
+ })
518
+ class Page {
519
+ readonly mutation = injectMutation(() => ({
520
+ mutationFn: (_text: string, context) =>
521
+ sleep(10).then(() => context.client.getQueryData(key)),
522
+ }))
523
+ }
524
+
525
+ const rendered = await render(Page)
526
+
527
+ rendered.fixture.componentInstance.mutation.mutate('todo')
528
+
529
+ await vi.advanceTimersByTimeAsync(11)
530
+ rendered.fixture.detectChanges()
531
+
532
+ expect(
533
+ rendered.getByText('data: tag-from-this-client'),
534
+ ).toBeInTheDocument()
535
+ })
536
+
537
+ it('should include mutationKey in the context passed to hook-level callbacks', async () => {
538
+ const onSuccess = vi.fn()
539
+ const mutation = TestBed.runInInjectionContext(() => {
540
+ return injectMutation(() => ({
541
+ mutationKey: ['todos', 'add'],
542
+ mutationFn: (text: string) => sleep(10).then(() => text),
543
+ onSuccess,
544
+ }))
545
+ })
546
+
547
+ mutation.mutate('todo')
548
+
549
+ await vi.advanceTimersByTimeAsync(10)
550
+
551
+ expect(onSuccess).toHaveBeenCalledTimes(1)
552
+ expect(onSuccess.mock.calls[0]?.[3].mutationKey).toEqual(['todos', 'add'])
553
+ })
554
+
555
+ it('should let onSuccess invalidate queries via context.client without an injected QueryClient', async () => {
556
+ const key = queryKey()
557
+ queryClient.setQueryData(key, 'data')
558
+
559
+ const mutation = TestBed.runInInjectionContext(() => {
560
+ return injectMutation(() => ({
561
+ mutationFn: () => sleep(10).then(() => 'mutated'),
562
+ onSuccess: (_data, _variables, _onMutateResult, context) => {
563
+ context.client.invalidateQueries({ queryKey: key })
564
+ },
565
+ }))
566
+ })
567
+
568
+ expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false)
569
+
570
+ mutation.mutate()
571
+
572
+ await vi.advanceTimersByTimeAsync(10)
573
+
574
+ expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)
575
+ })
576
+
577
+ it('should give a per-call onSuccess the same QueryClient instance via context', async () => {
578
+ const onSuccessMutate = vi.fn()
579
+ const mutation = TestBed.runInInjectionContext(() => {
580
+ return injectMutation(() => ({
581
+ mutationFn: (text: string) => sleep(10).then(() => text),
582
+ }))
583
+ })
584
+
585
+ mutation.mutate('todo', { onSuccess: onSuccessMutate })
586
+
587
+ await vi.advanceTimersByTimeAsync(10)
588
+
589
+ expect(onSuccessMutate).toHaveBeenCalledTimes(1)
590
+ expect(onSuccessMutate.mock.calls[0]?.[3].client).toBe(queryClient)
591
+ })
592
+ })
593
+
594
+ it('should support required signal inputs', async () => {
595
+ const mutationCache = queryClient.getMutationCache()
596
+
597
+ @Component({
598
+ selector: 'app-fake',
599
+ template: `
600
+ <button (click)="mutate()"></button>
601
+ <span>{{ mutation.data() }}</span>
602
+ `,
603
+ })
604
+ class FakeComponent {
605
+ name = input.required<string>()
606
+
607
+ mutation = injectMutation(() => ({
608
+ mutationKey: ['fake', this.name()],
609
+ mutationFn: () => sleep(10).then(() => this.name()),
610
+ }))
611
+
612
+ mutate(): void {
613
+ this.mutation.mutate()
614
+ }
615
+ }
616
+
617
+ const fixture = TestBed.createComponent(FakeComponent)
618
+ const { debugElement } = fixture
619
+ setFixtureSignalInputs(fixture, { name: 'value' })
620
+
621
+ const button = debugElement.query(By.css('button'))
622
+ button.triggerEventHandler('click')
623
+
624
+ await vi.advanceTimersByTimeAsync(11)
625
+ fixture.detectChanges()
626
+
627
+ const text = debugElement.query(By.css('span')).nativeElement.textContent
628
+ expect(text).toEqual('value')
629
+ const mutation = mutationCache.find({ mutationKey: ['fake', 'value'] })
630
+ expect(mutation?.options.mutationKey).toStrictEqual(['fake', 'value'])
631
+ })
632
+
633
+ it('should update options on required signal input change', async () => {
634
+ const mutationCache = queryClient.getMutationCache()
635
+
636
+ @Component({
637
+ selector: 'app-fake',
638
+ template: `
639
+ <button (click)="mutate()"></button>
640
+ <span>{{ mutation.data() }}</span>
641
+ `,
642
+ })
643
+ class FakeComponent {
644
+ name = input.required<string>()
645
+
646
+ mutation = injectMutation(() => ({
647
+ mutationKey: ['fake', this.name()],
648
+ mutationFn: () => sleep(10).then(() => this.name()),
649
+ }))
650
+
651
+ mutate(): void {
652
+ this.mutation.mutate()
653
+ }
654
+ }
655
+
656
+ const fixture = TestBed.createComponent(FakeComponent)
657
+ const { debugElement } = fixture
658
+ setFixtureSignalInputs(fixture, { name: 'value' })
659
+
660
+ const button = debugElement.query(By.css('button'))
661
+ const span = debugElement.query(By.css('span'))
662
+
663
+ button.triggerEventHandler('click')
664
+ await vi.advanceTimersByTimeAsync(11)
665
+ fixture.detectChanges()
666
+
667
+ expect(span.nativeElement.textContent).toEqual('value')
668
+
669
+ setFixtureSignalInputs(fixture, { name: 'updatedValue' })
670
+
671
+ button.triggerEventHandler('click')
672
+ await vi.advanceTimersByTimeAsync(11)
673
+ fixture.detectChanges()
674
+
675
+ expect(span.nativeElement.textContent).toEqual('updatedValue')
676
+
677
+ const mutations = mutationCache.findAll()
678
+ expect(mutations.length).toBe(2)
679
+ const [mutation1, mutation2] = mutations
680
+ expect(mutation1!.options.mutationKey).toEqual(['fake', 'value'])
681
+ expect(mutation2!.options.mutationKey).toEqual(['fake', 'updatedValue'])
682
+ })
683
+
684
+ describe('throwOnError', () => {
685
+ it('should evaluate throwOnError when mutation is expected to throw', async () => {
686
+ const key = queryKey()
687
+ const err = new Error('Expected mock error. All is well!')
688
+ const throwOnError = vi.fn()
689
+ const { mutate } = TestBed.runInInjectionContext(() => {
690
+ return injectMutation(() => ({
691
+ mutationKey: key,
692
+ mutationFn: () => {
693
+ return Promise.reject(err)
694
+ },
695
+ throwOnError,
696
+ }))
697
+ })
698
+
699
+ TestBed.tick()
700
+
701
+ mutate()
702
+
703
+ await vi.advanceTimersByTimeAsync(0)
704
+
705
+ expect(throwOnError).toHaveBeenCalledTimes(1)
706
+ expect(throwOnError).toHaveBeenCalledWith(err)
707
+ })
708
+
709
+ it('should throw when throwOnError is true and mutate is used', async () => {
710
+ const key = queryKey()
711
+ const { mutate } = TestBed.runInInjectionContext(() => {
712
+ return injectMutation(() => ({
713
+ mutationKey: key,
714
+ mutationFn: () => {
715
+ return Promise.reject(
716
+ new Error('Expected mock error. All is well!'),
717
+ )
718
+ },
719
+ throwOnError: true,
720
+ }))
721
+ })
722
+
723
+ TestBed.tick()
724
+
725
+ mutate()
726
+
727
+ await expect(vi.advanceTimersByTimeAsync(0)).rejects.toThrow(
728
+ 'Expected mock error. All is well!',
729
+ )
730
+ })
731
+ })
732
+
733
+ it('should throw when throwOnError is true', async () => {
734
+ const key = queryKey()
735
+ const err = new Error('Expected mock error. All is well!')
736
+ const { mutateAsync } = TestBed.runInInjectionContext(() => {
737
+ return injectMutation(() => ({
738
+ mutationKey: key,
739
+ mutationFn: () => {
740
+ return Promise.reject(err)
741
+ },
742
+ throwOnError: true,
743
+ }))
744
+ })
745
+
746
+ await expect(() => mutateAsync()).rejects.toThrow(err)
747
+ })
748
+
749
+ it('should throw when throwOnError function returns true', async () => {
750
+ const key = queryKey()
751
+ const err = new Error('Expected mock error. All is well!')
752
+ const { mutateAsync } = TestBed.runInInjectionContext(() => {
753
+ return injectMutation(() => ({
754
+ mutationKey: key,
755
+ mutationFn: () => {
756
+ return Promise.reject(err)
757
+ },
758
+ throwOnError: () => true,
759
+ }))
760
+ })
761
+
762
+ await expect(() => mutateAsync()).rejects.toThrow(err)
763
+ })
764
+
765
+ it('should resolve mutateAsync with the value returned from mutationFn', async () => {
766
+ const key = queryKey()
767
+ const { mutateAsync } = TestBed.runInInjectionContext(() => {
768
+ return injectMutation(() => ({
769
+ mutationKey: key,
770
+ mutationFn: (params: string) => sleep(10).then(() => params),
771
+ }))
772
+ })
773
+
774
+ const promise = mutateAsync('Mock data')
775
+ await vi.advanceTimersByTimeAsync(10)
776
+
777
+ await expect(promise).resolves.toBe('Mock data')
778
+ })
779
+
780
+ describe('injection context', () => {
781
+ it('should throw NG0203 with descriptive error outside injection context', () => {
782
+ const key = queryKey()
783
+ expect(() => {
784
+ injectMutation(() => ({
785
+ mutationKey: key,
786
+ mutationFn: () => Promise.resolve(),
787
+ }))
788
+ }).toThrow(/NG0203(.*?)injectMutation/)
789
+ })
790
+
791
+ it('should be usable outside injection context when passing an injector', () => {
792
+ const key = queryKey()
793
+ expect(() => {
794
+ injectMutation(
795
+ () => ({
796
+ mutationKey: key,
797
+ mutationFn: () => Promise.resolve(),
798
+ }),
799
+ {
800
+ injector: TestBed.inject(Injector),
801
+ },
802
+ )
803
+ }).not.toThrow()
804
+ })
805
+
806
+ it('should complete mutation before whenStable() resolves', async () => {
807
+ const app = TestBed.inject(ApplicationRef)
808
+ let mutationStarted = false
809
+ let mutationCompleted = false
810
+
811
+ const key = queryKey()
812
+ const mutation = TestBed.runInInjectionContext(() =>
813
+ injectMutation(() => ({
814
+ mutationKey: key,
815
+ mutationFn: async (data: string) => {
816
+ mutationStarted = true
817
+ await sleep(50)
818
+ mutationCompleted = true
819
+ return `processed: ${data}`
820
+ },
821
+ })),
822
+ )
823
+
824
+ // Initial state
825
+ expect(mutation.data()).toBeUndefined()
826
+ expect(mutationStarted).toBe(false)
827
+
828
+ // Start mutation
829
+ mutation.mutate('test')
830
+
831
+ // Wait for mutation to start and Angular to be "stable"
832
+ const stablePromise = app.whenStable()
833
+ await vi.advanceTimersByTimeAsync(60)
834
+ await stablePromise
835
+
836
+ // After whenStable(), mutation should be complete
837
+ expect(mutationStarted).toBe(true)
838
+ expect(mutationCompleted).toBe(true)
839
+ expect(mutation.isSuccess()).toBe(true)
840
+ expect(mutation.data()).toBe('processed: test')
841
+ })
842
+
843
+ it('should handle synchronous mutation with retry', async () => {
844
+ const app = TestBed.inject(ApplicationRef)
845
+ let attemptCount = 0
846
+
847
+ const mutation = TestBed.runInInjectionContext(() =>
848
+ injectMutation(() => ({
849
+ retry: 2,
850
+ retryDelay: 0, // No delay for synchronous retry
851
+ mutationFn: async (data: string) => {
852
+ attemptCount++
853
+ if (attemptCount <= 2) {
854
+ throw new Error(`Sync attempt ${attemptCount} failed`)
855
+ }
856
+ return `processed: ${data}`
857
+ },
858
+ })),
859
+ )
860
+
861
+ // Start mutation
862
+ mutation.mutate('retry-test')
863
+
864
+ // Synchronize pending effects for each retry attempt
865
+ TestBed.tick()
866
+ await Promise.resolve()
867
+ await vi.advanceTimersByTimeAsync(10)
868
+
869
+ TestBed.tick()
870
+ await Promise.resolve()
871
+ await vi.advanceTimersByTimeAsync(10)
872
+
873
+ TestBed.tick()
874
+
875
+ const stablePromise = app.whenStable()
876
+ await Promise.resolve()
877
+ await vi.advanceTimersByTimeAsync(10)
878
+ await stablePromise
879
+
880
+ expect(mutation.isSuccess()).toBe(true)
881
+ expect(mutation.data()).toBe('processed: retry-test')
882
+ expect(attemptCount).toBe(3) // Initial + 2 retries
883
+ })
884
+
885
+ it('should handle multiple synchronous mutations on same key', async () => {
886
+ const app = TestBed.inject(ApplicationRef)
887
+ let callCount = 0
888
+
889
+ const key = queryKey()
890
+
891
+ const mutation1 = TestBed.runInInjectionContext(() =>
892
+ injectMutation(() => ({
893
+ mutationKey: key,
894
+ mutationFn: async (data: string) => {
895
+ callCount++
896
+ return `mutation1: ${data}`
897
+ },
898
+ })),
899
+ )
900
+
901
+ const mutation2 = TestBed.runInInjectionContext(() =>
902
+ injectMutation(() => ({
903
+ mutationKey: key,
904
+ mutationFn: async (data: string) => {
905
+ callCount++
906
+ return `mutation2: ${data}`
907
+ },
908
+ })),
909
+ )
910
+
911
+ // Start both mutations
912
+ mutation1.mutate('test1')
913
+ mutation2.mutate('test2')
914
+
915
+ // Synchronize pending effects
916
+ TestBed.tick()
917
+
918
+ const stablePromise = app.whenStable()
919
+ // Flush microtasks to allow TanStack Query's scheduled notifications to process
920
+ await Promise.resolve()
921
+ await vi.advanceTimersByTimeAsync(1)
922
+ await stablePromise
923
+
924
+ expect(mutation1.isSuccess()).toBe(true)
925
+ expect(mutation1.data()).toBe('mutation1: test1')
926
+ expect(mutation2.isSuccess()).toBe(true)
927
+ expect(mutation2.data()).toBe('mutation2: test2')
928
+ expect(callCount).toBe(2)
929
+ })
930
+
931
+ it('should handle synchronous mutation with optimistic updates', async () => {
932
+ const app = TestBed.inject(ApplicationRef)
933
+ const testQueryKey = queryKey()
934
+ let onMutateCalled = false
935
+ let onSuccessCalled = false
936
+
937
+ // Set initial data
938
+ queryClient.setQueryData(testQueryKey, 'initial')
939
+
940
+ const mutation = TestBed.runInInjectionContext(() =>
941
+ injectMutation(() => ({
942
+ mutationFn: async (data: string) => `final: ${data}`, // Synchronous resolution
943
+ onMutate: async (variables) => {
944
+ onMutateCalled = true
945
+ const previousData = queryClient.getQueryData(testQueryKey)
946
+ queryClient.setQueryData(testQueryKey, `optimistic: ${variables}`)
947
+ return { previousData }
948
+ },
949
+ onSuccess: (data) => {
950
+ onSuccessCalled = true
951
+ queryClient.setQueryData(testQueryKey, data)
952
+ },
953
+ })),
954
+ )
955
+
956
+ // Start mutation
957
+ mutation.mutate('test')
958
+
959
+ // Synchronize pending effects
960
+ TestBed.tick()
961
+
962
+ const stablePromise = app.whenStable()
963
+ // Flush microtasks to allow TanStack Query's scheduled notifications to process
964
+ await Promise.resolve()
965
+ await vi.advanceTimersByTimeAsync(1)
966
+ await stablePromise
967
+
968
+ expect(onMutateCalled).toBe(true)
969
+ expect(onSuccessCalled).toBe(true)
970
+ expect(mutation.isSuccess()).toBe(true)
971
+ expect(mutation.data()).toBe('final: test')
972
+ expect(queryClient.getQueryData(testQueryKey)).toBe('final: test')
973
+ })
974
+
975
+ it('should handle synchronous mutation cancellation', async () => {
976
+ const app = TestBed.inject(ApplicationRef)
977
+
978
+ const key = queryKey()
979
+ const mutation = TestBed.runInInjectionContext(() =>
980
+ injectMutation(() => ({
981
+ mutationKey: key,
982
+ mutationFn: async (data: string) => `processed: ${data}`, // Synchronous resolution
983
+ })),
984
+ )
985
+
986
+ // Start mutation
987
+ mutation.mutate('test')
988
+
989
+ // Synchronize pending effects
990
+ TestBed.tick()
991
+
992
+ const stablePromise = app.whenStable()
993
+ // Flush microtasks to allow TanStack Query's scheduled notifications to process
994
+ await Promise.resolve()
995
+ await vi.advanceTimersByTimeAsync(1)
996
+ await stablePromise
997
+
998
+ // Synchronous mutations complete immediately
999
+ expect(mutation.isSuccess()).toBe(true)
1000
+ expect(mutation.data()).toBe('processed: test')
1001
+ })
1002
+ })
1003
+
1004
+ describe('optimistic updates', () => {
1005
+ it('should update the cache in onMutate and roll back via onMutateResult in onError', async () => {
1006
+ const key = queryKey()
1007
+ queryClient.setQueryData<Array<string>>(key, ['Todo 1'])
1008
+
1009
+ @Component({
1010
+ template: `<div>isError: {{ mutation.isError() }}</div>`,
1011
+ })
1012
+ class Page {
1013
+ readonly mutation = injectMutation(() => ({
1014
+ mutationFn: () =>
1015
+ sleep(10).then(() => Promise.reject(new Error('Some error'))),
1016
+ onMutate: async (newTodo: string) => {
1017
+ await queryClient.cancelQueries({ queryKey: key })
1018
+ const previousTodos = queryClient.getQueryData<Array<string>>(key)
1019
+
1020
+ queryClient.setQueryData<Array<string>>(key, (old) => [
1021
+ ...(old ?? []),
1022
+ newTodo,
1023
+ ])
1024
+
1025
+ return { previousTodos }
1026
+ },
1027
+ onError: (_err, _newTodo, onMutateResult) => {
1028
+ queryClient.setQueryData(key, onMutateResult?.previousTodos)
1029
+ },
1030
+ }))
1031
+ }
1032
+
1033
+ const rendered = await render(Page)
1034
+
1035
+ rendered.fixture.componentInstance.mutation.mutate('Todo 2')
1036
+ // onMutate runs synchronously up to its first await, so the optimistic
1037
+ // value is visible immediately, before the mutationFn settles.
1038
+ await vi.advanceTimersByTimeAsync(0)
1039
+
1040
+ expect(queryClient.getQueryData(key)).toEqual(['Todo 1', 'Todo 2'])
1041
+
1042
+ await vi.advanceTimersByTimeAsync(11)
1043
+ rendered.fixture.detectChanges()
1044
+
1045
+ expect(rendered.getByText('isError: true')).toBeInTheDocument()
1046
+ expect(queryClient.getQueryData(key)).toEqual(['Todo 1'])
1047
+ })
1048
+
1049
+ it('should keep the optimistic update in place when the mutation succeeds', async () => {
1050
+ const key = queryKey()
1051
+ queryClient.setQueryData<Array<string>>(key, ['Todo 1'])
1052
+
1053
+ @Component({
1054
+ template: `<div>isSuccess: {{ mutation.isSuccess() }}</div>`,
1055
+ })
1056
+ class Page {
1057
+ readonly mutation = injectMutation(() => ({
1058
+ mutationFn: (newTodo: string) => sleep(10).then(() => newTodo),
1059
+ onMutate: async (newTodo: string) => {
1060
+ await queryClient.cancelQueries({ queryKey: key })
1061
+ const previousTodos = queryClient.getQueryData<Array<string>>(key)
1062
+
1063
+ queryClient.setQueryData<Array<string>>(key, (old) => [
1064
+ ...(old ?? []),
1065
+ newTodo,
1066
+ ])
1067
+
1068
+ return { previousTodos }
1069
+ },
1070
+ onError: (_err, _newTodo, onMutateResult) => {
1071
+ queryClient.setQueryData(key, onMutateResult?.previousTodos)
1072
+ },
1073
+ }))
1074
+ }
1075
+
1076
+ const rendered = await render(Page)
1077
+
1078
+ rendered.fixture.componentInstance.mutation.mutate('Todo 2')
1079
+ await vi.advanceTimersByTimeAsync(11)
1080
+ rendered.fixture.detectChanges()
1081
+
1082
+ expect(rendered.getByText('isSuccess: true')).toBeInTheDocument()
1083
+ expect(queryClient.getQueryData(key)).toEqual(['Todo 1', 'Todo 2'])
1084
+ })
1085
+ })
1086
+
1087
+ describe('concurrent mutate calls', () => {
1088
+ it('should report each mutateAsync call result independently when some fail', async () => {
1089
+ @Component({
1090
+ template: `<div>isPending: {{ mutation.isPending() }}</div>`,
1091
+ })
1092
+ class Page {
1093
+ readonly mutation = injectMutation(() => ({
1094
+ mutationFn: (todo: string) =>
1095
+ todo === 'bad'
1096
+ ? sleep(10).then(() => Promise.reject(new Error('Some error')))
1097
+ : sleep(10).then(() => todo),
1098
+ }))
1099
+ }
1100
+
1101
+ const rendered = await render(Page)
1102
+ const todos = ['Todo 1', 'bad', 'Todo 3']
1103
+
1104
+ expect(rendered.getByText('isPending: false')).toBeInTheDocument()
1105
+
1106
+ const settledPromise = Promise.allSettled(
1107
+ todos.map((todo) =>
1108
+ rendered.fixture.componentInstance.mutation.mutateAsync(todo),
1109
+ ),
1110
+ )
1111
+ await vi.advanceTimersByTimeAsync(0)
1112
+ rendered.fixture.detectChanges()
1113
+
1114
+ expect(rendered.getByText('isPending: true')).toBeInTheDocument()
1115
+
1116
+ await vi.advanceTimersByTimeAsync(11)
1117
+ rendered.fixture.detectChanges()
1118
+ const results = await settledPromise
1119
+
1120
+ expect(results).toEqual([
1121
+ { status: 'fulfilled', value: 'Todo 1' },
1122
+ { status: 'rejected', reason: Error('Some error') },
1123
+ { status: 'fulfilled', value: 'Todo 3' },
1124
+ ])
1125
+ expect(rendered.getByText('isPending: false')).toBeInTheDocument()
1126
+ })
1127
+
1128
+ it('should only fire the per-call onSuccess for the last mutate() call', async () => {
1129
+ const onSuccessMutate = vi.fn()
1130
+
1131
+ @Component({
1132
+ template: `<div>data: {{ mutation.data() ?? 'none' }}</div>`,
1133
+ })
1134
+ class Page {
1135
+ readonly mutation = injectMutation(() => ({
1136
+ mutationFn: (todo: string) => sleep(10).then(() => todo),
1137
+ }))
1138
+ }
1139
+
1140
+ const rendered = await render(Page)
1141
+
1142
+ rendered.fixture.componentInstance.mutation.mutate('Todo 1', {
1143
+ onSuccess: onSuccessMutate,
1144
+ })
1145
+ rendered.fixture.componentInstance.mutation.mutate('Todo 2', {
1146
+ onSuccess: onSuccessMutate,
1147
+ })
1148
+ await vi.advanceTimersByTimeAsync(11)
1149
+ rendered.fixture.detectChanges()
1150
+
1151
+ expect(onSuccessMutate).toHaveBeenCalledTimes(1)
1152
+ expect(onSuccessMutate).toHaveBeenCalledWith(
1153
+ 'Todo 2',
1154
+ 'Todo 2',
1155
+ undefined,
1156
+ expect.anything(),
1157
+ )
1158
+ expect(rendered.getByText('data: Todo 2')).toBeInTheDocument()
1159
+ })
1160
+ })
1161
+ })