| author | |
| committer | |
| log | 1ea8a97377ab12b84a6d111aa16a911898376d94 |
| tree | 8d8f5ebdd29e6b02f1c3ef015630eaeb544fe9fc |
| parent | cd0b36c24e6342b64f505e21d51dec1fb4a72199 |
| signature |
8 files changed, 362 insertions(+), 93 deletions(-)
jsr.json+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "@clo/react-mutation", |
| 3 | "version": "1.0.0-beta.12", | |
| 3 | "version": "1.0.0-beta.13", | |
| 4 | 4 | "exports": { |
| 5 | 5 | ".": "./src/mod.ts", |
| 6 | 6 | "./tanstack-query.ts": "./src/tanstack-query.ts", |
src/mutation.ts+3-3| ... | ... | @@ -229,7 +229,7 @@ export class BlockingMutation< |
| 229 | 229 | } |
| 230 | 230 | |
| 231 | 231 | /** Calls the mutation with custom handlers that can suppress global handlers. */ |
| 232 | runWithOptions(...array: [...Args, RunOptions<Result>]): Promise<Result> { | |
| 232 | runWithOptions(...array: [...Args, RunOptions<Result>]): void { | |
| 233 | 233 | if (!this.#client.enabled) { |
| 234 | 234 | throw new Error( |
| 235 | 235 | "MutationClient was passed enabled: false. Are you trying to perform a mutation from SSR?", |
| ... | ... | @@ -266,8 +266,6 @@ export class BlockingMutation< |
| 266 | 266 | this.#client.reportError(formatFriendlyError(this.describe(...args), error), error); |
| 267 | 267 | } |
| 268 | 268 | }); |
| 269 | ||
| 270 | return promise; | |
| 271 | 269 | } |
| 272 | 270 | |
| 273 | 271 | /** Calls the mutation, treating the errors as promise rejection. */ |
| ... | ... | @@ -415,6 +413,8 @@ export class BlockingMutation< |
| 415 | 413 | } else { |
| 416 | 414 | // Discard refetch callbacks if refetchOnSuccess is false |
| 417 | 415 | channel.refetches = []; |
| 416 | // Notify listeners with success status and result before moving to next | |
| 417 | this.#notify(channel, "mutating", result); | |
| 418 | 418 | this.#executeNext(key, channel); |
| 419 | 419 | } |
| 420 | 420 | resolve(result); |
src/react.ts+10-3| ... | ... | @@ -45,7 +45,7 @@ export interface UseMutateResultBase<Args extends unknown[], Result> { |
| 45 | 45 | run: (...args: Args) => void; |
| 46 | 46 | runWithOptions: ( |
| 47 | 47 | ..._: [...args: Args, options: RunOptions<Result>] |
| 48 | ) => Promise<Result>; | |
| 48 | ) => void; | |
| 49 | 49 | clear: () => void; |
| 50 | 50 | setError: (error: unknown) => void; |
| 51 | 51 | args: Args | undefined; |
| ... | ... | @@ -89,6 +89,8 @@ export interface UseMutateIdle { |
| 89 | 89 | result: undefined; |
| 90 | 90 | error: undefined; |
| 91 | 91 | errorMessage: undefined; |
| 92 | /** `true` when controls should be disabled */ | |
| 93 | isDisabled: boolean; | |
| 92 | 94 | /** `true` when a `mutate` function is currently running. */ |
| 93 | 95 | isMutating: boolean; |
| 94 | 96 | /** `true` when a loading indicator should be shown. */ |
| ... | ... | @@ -314,8 +316,8 @@ class Observer<Args extends unknown[], Result> { |
| 314 | 316 | ); |
| 315 | 317 | } |
| 316 | 318 | |
| 317 | // Delegate to the mutation's runWithOptions | |
| 318 | mutation.runWithOptions(...args, options); | |
| 319 | // Delegate to the mutation's runWithOptions and return the promise | |
| 320 | return mutation.runWithOptions(...args, options); | |
| 319 | 321 | } |
| 320 | 322 | |
| 321 | 323 | binding: UseMutateResult<Args, Result> = ((self: this) => ({ |
| ... | ... | @@ -363,6 +365,11 @@ class Observer<Args extends unknown[], Result> { |
| 363 | 365 | self.watched.add("isMutating"); |
| 364 | 366 | return self.state.isMutating; |
| 365 | 367 | }, |
| 368 | // TODO: when auth drops this will be dependant on the auth status and isMutating | |
| 369 | get isDisabled() { | |
| 370 | self.watched.add("isMutating"); | |
| 371 | return self.state.isMutating; | |
| 372 | }, | |
| 366 | 373 | get isPending() { |
| 367 | 374 | self.watched.add("isPending"); |
| 368 | 375 | return self.state.isPending; |
test/cases/runWithOptions.test.tsx created+61| ... | ... | @@ -0,0 +1,61 @@ |
| 1 | import { useMutate } from "@clo/react-mutation"; | |
| 2 | import { assertEquals } from "@std/assert"; | |
| 3 | import { act, render, screen } from "@testing-library/react"; | |
| 4 | import { userEvent } from "@testing-library/user-event"; | |
| 5 | import { test, vi } from "vitest"; | |
| 6 | import { createTestMutationClient, IterableStream } from "../share.ts"; | |
| 7 | ||
| 8 | test("runWithOptions should allow react hook to do local handling", async () => { | |
| 9 | vi.useFakeTimers({ shouldAdvanceTime: true }); | |
| 10 | const user = userEvent.setup({ delay: null }); | |
| 11 | ||
| 12 | const { client, successMessages, errorMessages } = createTestMutationClient(); | |
| 13 | const s = new IterableStream<string>(); | |
| 14 | ||
| 15 | const mutTest = client.define({ | |
| 16 | mutate: async () => { | |
| 17 | return (await s.next()).value; | |
| 18 | }, | |
| 19 | describe: "Test the action", | |
| 20 | describeResult: "Tested the action", | |
| 21 | optimistic: () => {}, | |
| 22 | refetchOnSuccess: false, | |
| 23 | }); | |
| 24 | ||
| 25 | let renders: Array<{ status: string; result: string | undefined }> = []; | |
| 26 | function TestComponent() { | |
| 27 | const { runWithOptions, status, result } = useMutate(mutTest); | |
| 28 | renders.push({ status, result }); | |
| 29 | return ( | |
| 30 | <button | |
| 31 | data-testid="a" | |
| 32 | onClick={() => { | |
| 33 | runWithOptions({ onSuccessDataOnly: () => {} }); | |
| 34 | }} | |
| 35 | > | |
| 36 | button | |
| 37 | </button> | |
| 38 | ); | |
| 39 | } | |
| 40 | ||
| 41 | render(<TestComponent />); | |
| 42 | // initial state | |
| 43 | assertEquals(renders, [{ status: "idle", result: undefined }]); | |
| 44 | assertEquals(successMessages, []); | |
| 45 | assertEquals(errorMessages, []); | |
| 46 | renders = []; | |
| 47 | vi.runAllTimers(); | |
| 48 | ||
| 49 | // mutation 1 - success | |
| 50 | await act(() => user.click(screen.getByTestId("a"))); | |
| 51 | assertEquals(renders, [{ status: "mutating", result: undefined }]); | |
| 52 | renders = []; | |
| 53 | await act(async () => { | |
| 54 | s.push("ok"); | |
| 55 | vi.advanceTimersByTime(100); | |
| 56 | }); | |
| 57 | assertEquals(successMessages, ["Tested the action"]); | |
| 58 | assertEquals(errorMessages, []); | |
| 59 | assertEquals(renders, [{ status: "success", result: "ok" }]); | |
| 60 | renders = []; | |
| 61 | }); |
test/cases/setError.test.tsx created+260| ... | ... | @@ -0,0 +1,260 @@ |
| 1 | import { useMutate } from "@clo/react-mutation"; | |
| 2 | import { assertEquals } from "@std/assert"; | |
| 3 | import { act, render, screen } from "@testing-library/react"; | |
| 4 | import { userEvent } from "@testing-library/user-event"; | |
| 5 | import { test, vi } from "vitest"; | |
| 6 | import { createTestMutationClient, IterableStream } from "../share.ts"; | |
| 7 | ||
| 8 | test("setError should manually set error state on the hook", async () => { | |
| 9 | vi.useFakeTimers({ shouldAdvanceTime: true }); | |
| 10 | const user = userEvent.setup({ delay: null }); | |
| 11 | ||
| 12 | const { client, successMessages, errorMessages } = createTestMutationClient(); | |
| 13 | ||
| 14 | const mutTest = client.define({ | |
| 15 | mutate: async () => { | |
| 16 | return "success"; | |
| 17 | }, | |
| 18 | describe: "Test the action", | |
| 19 | describeResult: "Tested the action", | |
| 20 | optimistic: () => {}, | |
| 21 | }); | |
| 22 | ||
| 23 | const manualError = new Error("Manual error"); | |
| 24 | ||
| 25 | let renders: Array<{ | |
| 26 | status: string; | |
| 27 | result: string | undefined; | |
| 28 | error: unknown; | |
| 29 | errorMessage: string | undefined; | |
| 30 | isError: boolean; | |
| 31 | isSuccess: boolean; | |
| 32 | }> = []; | |
| 33 | ||
| 34 | function TestComponent() { | |
| 35 | const { setError, status, result, error, errorMessage, isError, isSuccess } = useMutate( | |
| 36 | mutTest, | |
| 37 | ); | |
| 38 | renders.push({ status, result, error, errorMessage, isError, isSuccess }); | |
| 39 | return ( | |
| 40 | <button | |
| 41 | data-testid="set-error-btn" | |
| 42 | onClick={() => { | |
| 43 | setError(manualError); | |
| 44 | }} | |
| 45 | > | |
| 46 | Set Error | |
| 47 | </button> | |
| 48 | ); | |
| 49 | } | |
| 50 | ||
| 51 | render(<TestComponent />); | |
| 52 | ||
| 53 | // initial state - idle | |
| 54 | assertEquals(renders, [ | |
| 55 | { | |
| 56 | status: "idle", | |
| 57 | result: undefined, | |
| 58 | error: undefined, | |
| 59 | errorMessage: undefined, | |
| 60 | isError: false, | |
| 61 | isSuccess: false, | |
| 62 | }, | |
| 63 | ]); | |
| 64 | assertEquals(successMessages, []); | |
| 65 | assertEquals(errorMessages, []); | |
| 66 | renders = []; | |
| 67 | vi.runAllTimers(); | |
| 68 | ||
| 69 | // manually set error using setError | |
| 70 | await act(() => user.click(screen.getByTestId("set-error-btn"))); | |
| 71 | assertEquals(renders, [ | |
| 72 | { | |
| 73 | status: "error", | |
| 74 | result: undefined, | |
| 75 | error: manualError, | |
| 76 | errorMessage: "Manual error", | |
| 77 | isError: true, | |
| 78 | isSuccess: false, | |
| 79 | }, | |
| 80 | ]); | |
| 81 | // setError should not trigger global error/success handlers | |
| 82 | assertEquals(successMessages, []); | |
| 83 | assertEquals(errorMessages, []); | |
| 84 | renders = []; | |
| 85 | }); | |
| 86 | ||
| 87 | test("setError should override success state", async () => { | |
| 88 | vi.useFakeTimers({ shouldAdvanceTime: true }); | |
| 89 | const user = userEvent.setup({ delay: null }); | |
| 90 | ||
| 91 | const { client, successMessages, errorMessages } = createTestMutationClient(); | |
| 92 | const s = new IterableStream<string>(); | |
| 93 | ||
| 94 | const mutTest = client.define({ | |
| 95 | mutate: async () => { | |
| 96 | return (await s.next()).value; | |
| 97 | }, | |
| 98 | describe: "Test the action", | |
| 99 | describeResult: "Tested the action", | |
| 100 | optimistic: () => {}, | |
| 101 | }); | |
| 102 | ||
| 103 | const customError = "Custom error message"; | |
| 104 | ||
| 105 | let renders: Array<{ | |
| 106 | status: string; | |
| 107 | result: string | undefined; | |
| 108 | error: unknown; | |
| 109 | isError: boolean; | |
| 110 | isSuccess: boolean; | |
| 111 | }> = []; | |
| 112 | ||
| 113 | function TestComponent() { | |
| 114 | const { run, setError, status, result, error, isError, isSuccess } = useMutate(mutTest); | |
| 115 | renders.push({ status, result, error, isError, isSuccess }); | |
| 116 | return ( | |
| 117 | <div> | |
| 118 | <button | |
| 119 | data-testid="run-btn" | |
| 120 | onClick={() => { | |
| 121 | run(); | |
| 122 | }} | |
| 123 | > | |
| 124 | Run | |
| 125 | </button> | |
| 126 | <button | |
| 127 | data-testid="set-error-btn" | |
| 128 | onClick={() => { | |
| 129 | setError(customError); | |
| 130 | }} | |
| 131 | > | |
| 132 | Set Error | |
| 133 | </button> | |
| 134 | </div> | |
| 135 | ); | |
| 136 | } | |
| 137 | ||
| 138 | render(<TestComponent />); | |
| 139 | ||
| 140 | // initial state | |
| 141 | assertEquals(renders, [ | |
| 142 | { | |
| 143 | status: "idle", | |
| 144 | result: undefined, | |
| 145 | error: undefined, | |
| 146 | isError: false, | |
| 147 | isSuccess: false, | |
| 148 | }, | |
| 149 | ]); | |
| 150 | renders = []; | |
| 151 | vi.runAllTimers(); | |
| 152 | ||
| 153 | // run mutation - should succeed | |
| 154 | await act(() => user.click(screen.getByTestId("run-btn"))); | |
| 155 | assertEquals(renders, [ | |
| 156 | { | |
| 157 | status: "mutating", | |
| 158 | result: undefined, | |
| 159 | error: undefined, | |
| 160 | isError: false, | |
| 161 | isSuccess: false, | |
| 162 | }, | |
| 163 | ]); | |
| 164 | renders = []; | |
| 165 | ||
| 166 | await act(async () => { | |
| 167 | s.push("success result"); | |
| 168 | vi.advanceTimersByTime(100); | |
| 169 | }); | |
| 170 | ||
| 171 | // verify success state | |
| 172 | assertEquals(renders, [ | |
| 173 | { | |
| 174 | status: "success", | |
| 175 | result: "success result", | |
| 176 | error: undefined, | |
| 177 | isError: false, | |
| 178 | isSuccess: true, | |
| 179 | }, | |
| 180 | ]); | |
| 181 | assertEquals(successMessages, []); | |
| 182 | assertEquals(errorMessages, []); | |
| 183 | renders = []; | |
| 184 | ||
| 185 | // now manually set error - should override success state | |
| 186 | await act(() => user.click(screen.getByTestId("set-error-btn"))); | |
| 187 | assertEquals(renders, [ | |
| 188 | { | |
| 189 | status: "error", | |
| 190 | result: undefined, | |
| 191 | error: customError, | |
| 192 | isError: true, | |
| 193 | isSuccess: false, | |
| 194 | }, | |
| 195 | ]); | |
| 196 | // setError should not trigger global error handler | |
| 197 | assertEquals(successMessages, []); | |
| 198 | assertEquals(errorMessages, []); | |
| 199 | renders = []; | |
| 200 | }); | |
| 201 | ||
| 202 | test("setError should work with different error types", async () => { | |
| 203 | vi.useFakeTimers({ shouldAdvanceTime: true }); | |
| 204 | const user = userEvent.setup({ delay: null }); | |
| 205 | ||
| 206 | const { client } = createTestMutationClient(); | |
| 207 | ||
| 208 | const mutTest = client.define({ | |
| 209 | mutate: async () => { | |
| 210 | return "success"; | |
| 211 | }, | |
| 212 | describe: "Test the action", | |
| 213 | describeResult: null, | |
| 214 | optimistic: () => {}, | |
| 215 | }); | |
| 216 | ||
| 217 | let lastErrorMessage: string | undefined; | |
| 218 | ||
| 219 | function TestComponent() { | |
| 220 | const { setError, errorMessage } = useMutate(mutTest); | |
| 221 | lastErrorMessage = errorMessage; | |
| 222 | return ( | |
| 223 | <div> | |
| 224 | <button | |
| 225 | data-testid="set-string-error" | |
| 226 | onClick={() => setError("String error")} | |
| 227 | > | |
| 228 | String | |
| 229 | </button> | |
| 230 | <button | |
| 231 | data-testid="set-error-object" | |
| 232 | onClick={() => setError(new Error("Error object"))} | |
| 233 | > | |
| 234 | Error | |
| 235 | </button> | |
| 236 | <button | |
| 237 | data-testid="set-number-error" | |
| 238 | onClick={() => setError(42)} | |
| 239 | > | |
| 240 | Number | |
| 241 | </button> | |
| 242 | </div> | |
| 243 | ); | |
| 244 | } | |
| 245 | ||
| 246 | render(<TestComponent />); | |
| 247 | vi.runAllTimers(); | |
| 248 | ||
| 249 | // Test string error | |
| 250 | await act(() => user.click(screen.getByTestId("set-string-error"))); | |
| 251 | assertEquals(lastErrorMessage, "String error"); | |
| 252 | ||
| 253 | // Test Error object | |
| 254 | await act(() => user.click(screen.getByTestId("set-error-object"))); | |
| 255 | assertEquals(lastErrorMessage, "Error object"); | |
| 256 | ||
| 257 | // Test number (should be converted to string) | |
| 258 | await act(() => user.click(screen.getByTestId("set-number-error"))); | |
| 259 | assertEquals(lastErrorMessage, "42"); | |
| 260 | }); |
test/useMutate.test.tsx+18-85| ... | ... | @@ -211,6 +211,24 @@ test("useMutate - local error and success handling", async () => { |
| 211 | 211 | }]); |
| 212 | 212 | renders = []; |
| 213 | 213 | |
| 214 | // clear state | |
| 215 | vi.runAllTimers(); | |
| 216 | await act(() => user.click(screen.getByTestId("b"))); | |
| 217 | assertEquals(renders, [{ | |
| 218 | error: undefined, | |
| 219 | errorMessage: undefined, | |
| 220 | isError: false, | |
| 221 | isMutating: false, | |
| 222 | isOptimisticData: false, | |
| 223 | isPending: false, | |
| 224 | isSuccess: false, | |
| 225 | result: undefined, | |
| 226 | }]); | |
| 227 | renders = []; | |
| 228 | await act(() => user.click(screen.getByTestId("b"))); | |
| 229 | assertEquals(renders, []); // nothing changed | |
| 230 | renders = []; | |
| 231 | ||
| 214 | 232 | // mutation 2 - failure |
| 215 | 233 | await act(() => user.click(screen.getByTestId("a"))); |
| 216 | 234 | assertEquals(renders, [{ |
| ... | ... | @@ -256,88 +274,3 @@ test("useMutate - local error and success handling", async () => { |
| 256 | 274 | assertEquals(successMessages, []); |
| 257 | 275 | assertEquals(errorMessages, []); |
| 258 | 276 | }); |
| 259 | ||
| 260 | test("useMutate - global error and success handling", async () => { | |
| 261 | vi.useFakeTimers({ shouldAdvanceTime: true }); | |
| 262 | const user = userEvent.setup({ delay: null }); | |
| 263 | ||
| 264 | const { client, successMessages, errorMessages } = createTestMutationClient(); | |
| 265 | const s = new IterableStream<string>(); | |
| 266 | ||
| 267 | const mutTest = client.define({ | |
| 268 | mutate: async () => { | |
| 269 | return (await s.next()).value; | |
| 270 | }, | |
| 271 | describe: "Test the action", | |
| 272 | describeResult: "Tested the action", | |
| 273 | optimistic: () => {}, | |
| 274 | }); | |
| 275 | ||
| 276 | let renders: Array<{ isMutating: boolean; isPending: boolean }> = []; | |
| 277 | function TestComponent() { | |
| 278 | const { run, isMutating, isPending } = useMutate(mutTest); | |
| 279 | renders.push({ isMutating, isPending }); | |
| 280 | ||
| 281 | return ( | |
| 282 | <> | |
| 283 | <button | |
| 284 | data-testid="a" | |
| 285 | onClick={() => { | |
| 286 | run(); | |
| 287 | }} | |
| 288 | > | |
| 289 | button | |
| 290 | </button> | |
| 291 | </> | |
| 292 | ); | |
| 293 | } | |
| 294 | ||
| 295 | render(<TestComponent />); | |
| 296 | // initial state | |
| 297 | assertEquals(renders, [{ isMutating: false, isPending: false }]); | |
| 298 | assertEquals(successMessages, []); | |
| 299 | assertEquals(errorMessages, []); | |
| 300 | renders = []; | |
| 301 | vi.runAllTimers(); | |
| 302 | ||
| 303 | // mutation 1 | |
| 304 | await act(() => user.click(screen.getByTestId("a"))); | |
| 305 | assertEquals(renders, [{ isMutating: true, isPending: false }]); | |
| 306 | renders = []; | |
| 307 | await act(() => vi.advanceTimersByTime(150)); | |
| 308 | assertEquals(renders, []); | |
| 309 | await act(() => vi.advanceTimersByTime(50)); | |
| 310 | assertEquals(renders, [{ isMutating: true, isPending: true }]); | |
| 311 | renders = []; | |
| 312 | assertEquals(successMessages, []); | |
| 313 | assertEquals(errorMessages, []); | |
| 314 | await act(async () => { | |
| 315 | s.push("ok"); | |
| 316 | vi.advanceTimersByTime(100); | |
| 317 | }); | |
| 318 | assertEquals(successMessages, ["Tested the action"]); | |
| 319 | assertEquals(errorMessages, []); | |
| 320 | assertEquals(renders, [{ isMutating: false, isPending: false }]); | |
| 321 | renders = []; | |
| 322 | ||
| 323 | // mutation 2 | |
| 324 | await act(() => user.click(screen.getByTestId("a"))); | |
| 325 | assertEquals(renders, [{ isMutating: true, isPending: false }]); | |
| 326 | renders = []; | |
| 327 | await act(() => vi.advanceTimersByTime(150)); | |
| 328 | assertEquals(renders, []); | |
| 329 | await act(() => vi.advanceTimersByTime(50)); | |
| 330 | assertEquals(renders, [{ isMutating: true, isPending: true }]); | |
| 331 | renders = []; | |
| 332 | assertEquals(successMessages, ["Tested the action"]); | |
| 333 | assertEquals(errorMessages, []); | |
| 334 | const error1 = new Error("damn!"); | |
| 335 | await act(async () => { | |
| 336 | s.throw(error1); | |
| 337 | vi.advanceTimersByTime(100); | |
| 338 | }); | |
| 339 | assertEquals(successMessages, ["Tested the action"]); | |
| 340 | assertEquals(errorMessages, [ | |
| 341 | { error: error1, message: "Could not test the action: damn!" }, | |
| 342 | ]); | |
| 343 | }); |
tsconfig.json+4-1| ... | ... | @@ -16,7 +16,10 @@ |
| 16 | 16 | "allowImportingTsExtensions": true, |
| 17 | 17 | "jsx": "react-jsx", |
| 18 | 18 | "verbatimModuleSyntax": true, |
| 19 | "types": ["react"] | |
| 19 | "types": ["react"], | |
| 20 | "paths": { | |
| 21 | "@clo/react-mutation": ["./src/mod.ts"] | |
| 22 | } | |
| 20 | 23 | }, |
| 21 | 24 | "include": ["src/**/*", "test/**/*"], |
| 22 | 25 | "exclude": ["node_modules"] |
vitest.config.ts+5| ... | ... | @@ -3,6 +3,11 @@ import { defineConfig } from "vitest/config"; |
| 3 | 3 | |
| 4 | 4 | export default defineConfig({ |
| 5 | 5 | plugins: [react()], |
| 6 | resolve: { | |
| 7 | alias: { | |
| 8 | "@clo/react-mutation": import.meta.resolve("./src/mod.ts"), | |
| 9 | }, | |
| 10 | }, | |
| 6 | 11 | test: { |
| 7 | 12 | globals: true, |
| 8 | 13 | // Use happy-dom for React tests, fallback to node for others |