diff --git a/gitbook/reactjrx/queries/react/usemutationusd.md b/gitbook/reactjrx/queries/react/usemutationusd.md index 6752ef0..5132658 100644 --- a/gitbook/reactjrx/queries/react/usemutationusd.md +++ b/gitbook/reactjrx/queries/react/usemutationusd.md @@ -1,2 +1,16 @@ # useMutation$ +## Run mutations one after another + +Mutations run in parallel by default. Mutations that share a `scope.id` run one after another instead: each one starts once the one before it has succeeded or failed. + +```tsx +const { mutate } = useMutation$({ + scope: { id: "save-settings" }, + mutationFn: (settings: Settings) => from(saveSettings(settings)), +}) +``` + +A mutation settles on the first value its stream emits, so the next one starts then, even if the stream stays open. + +`scope` is a TanStack Query option: the queue holds every mutation of the `QueryClient` with that id, whichever component started it. See [Mutation Scopes](https://tanstack.com/query/latest/docs/framework/react/guides/mutations#mutation-scopes). diff --git a/src/index.ts b/src/index.ts index c7d435f..d85d2f0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ export * from "./lib/binding/useObservableState" export * from "./lib/binding/useObserve" export * from "./lib/binding/useSubscribe" export * from "./lib/binding/useSubscribeEffect" -export * from "./lib/queries/useConcatMutation$" export * from "./lib/queries/useLiveQueries$" export * from "./lib/queries/useLiveQuery$" export * from "./lib/queries/useMutation$" diff --git a/src/lib/queries/mutationOptions.ts b/src/lib/queries/mutationOptions.ts index 327bd2e..d6bddf3 100644 --- a/src/lib/queries/mutationOptions.ts +++ b/src/lib/queries/mutationOptions.ts @@ -36,10 +36,9 @@ type AdaptedMutationCallbacks = { } /** - * Hooks built on top of `useMutation$` (`useSwitchMutation$`, - * `useConcatMutation$`) run their inner mutation with the user variables - * wrapped in an envelope (`{ variables, ... }`). This adapts the user-facing - * callbacks, which expect the raw variables, to that envelope. + * `useSwitchMutation$` runs its inner mutation with the user variables + * wrapped in an envelope (`{ variables, abort }`). This adapts the + * user-facing callbacks, which expect the raw variables, to that envelope. */ export function adaptCallbacksToWrappedVariables< TData, diff --git a/src/lib/queries/useConcatMutation$.ts b/src/lib/queries/useConcatMutation$.ts deleted file mode 100644 index 2a34ba3..0000000 --- a/src/lib/queries/useConcatMutation$.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - type DefaultError, - type MutationKey, - type QueryClient, - useQueryClient, -} from "@tanstack/react-query" -import { useCallback } from "react" -import { - BehaviorSubject, - filter, - first, - noop, - type Subject, - switchMap, -} from "rxjs" -import { - adaptCallbacksToWrappedVariables, - resolveMutationFnSource, -} from "./mutationOptions" -import { type UseMutation$Options, useMutation$ } from "./useMutation$" - -export function useConcatMutation$< - TData = unknown, - TError = DefaultError, - TVariables = void, - TContext = unknown, ->( - options: UseMutation$Options & { - mutationKey: MutationKey - }, - queryClient?: QueryClient, -) { - const client = useQueryClient(queryClient) - type TDataOrNull = TData | null - const mutationKey = options.mutationKey - - const { mutateAsync, ...rest } = useMutation$< - TDataOrNull, - TError, - { variables: TVariables; ready$: Subject }, - TContext - >( - { - ...options, - ...adaptCallbacksToWrappedVariables(options), - mutationFn: ({ ready$, variables }) => { - const source = resolveMutationFnSource(options.mutationFn, variables) - - return ready$.pipe( - filter((isReady) => isReady), - first(), - switchMap(() => source), - ) - }, - }, - queryClient, - ) - - const mutateAsyncConcat = useCallback( - async (variables: TVariables) => { - const mutations = client.getMutationCache().findAll({ - mutationKey, - exact: true, - }) - - const subject = new BehaviorSubject(false) - - const result = mutateAsync({ variables, ready$: subject }) - - await Promise.all( - mutations.map((mutation) => mutation.continue().catch(noop)), - ) - - subject.next(true) - - return await result.finally(() => { - subject.complete() - }) - }, - [mutateAsync, client, mutationKey], - ) - - const mutateConcat = useCallback( - (variables: TVariables) => { - mutateAsyncConcat(variables).catch(noop) - }, - [mutateAsyncConcat], - ) - - return { ...rest, mutate: mutateConcat, mutateAsync: mutateAsyncConcat } -} diff --git a/src/lib/queries/useMutation$.test.tsx b/src/lib/queries/useMutation$.test.tsx index 95bd8d4..acdd709 100644 --- a/src/lib/queries/useMutation$.test.tsx +++ b/src/lib/queries/useMutation$.test.tsx @@ -1,8 +1,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { cleanup, render } from "@testing-library/react" +import { + act, + cleanup, + render, + renderHook, + waitFor, +} from "@testing-library/react" import React, { useEffect } from "react" -import { finalize, ReplaySubject, takeUntil, timer } from "rxjs" +import { finalize, ReplaySubject, Subject, takeUntil, timer } from "rxjs" import { afterEach, describe, expect, it } from "vitest" +import { createWrapper } from "../../tests/liveQuery" import { waitForTimeout } from "../../tests/utils" import { useMutation$ } from "./useMutation$" @@ -69,4 +76,57 @@ describe("useMutation", () => { }) }) }) + + describe("Given mutations that share a scope", () => { + it("should start each one once the previous one has failed or emitted", async () => { + const sources = { + first: new Subject(), + second: new Subject(), + third: new Subject(), + } + const failure = new Error("first failed") + const started: string[] = [] + + const { result } = renderHook( + () => + useMutation$({ + scope: { id: "save" }, + mutationFn: (name: keyof typeof sources) => { + started.push(name) + + return sources[name] + }, + }), + { wrapper: createWrapper(new QueryClient()) }, + ) + + let settled!: Promise[]> + + act(() => { + settled = Promise.allSettled([ + result.current.mutateAsync("first"), + result.current.mutateAsync("second"), + result.current.mutateAsync("third"), + ]) + }) + + await waitFor(() => expect(started).toEqual(["first"])) + + act(() => sources.first.error(failure)) + + await waitFor(() => expect(started).toEqual(["first", "second"])) + + act(() => sources.second.next("second saved")) + + await waitFor(() => expect(started).toEqual(["first", "second", "third"])) + + act(() => sources.third.next("third saved")) + + expect(await settled).toEqual([ + { status: "rejected", reason: failure }, + { status: "fulfilled", value: "second saved" }, + { status: "fulfilled", value: "third saved" }, + ]) + }) + }) })