Skip to content

Commit db6846b

Browse files
committed
refactor(mutation-flow): positional signature + chain-based fallback
useMutationFlow goes back to two positional arguments: useMutationFlow(call, mutationFn) The Fallback response is now delivered at the Trigger callsite via a method chain instead of via the hook's options object: // Required handler const submit = useMutationFlow(call, mutationFn) <button onClick={() => submit()}>Yes</button> // Optional handler — chain .orEnd at the callsite const submit = useMutationFlow(call, mutationFn) <button onClick={() => submit().orEnd(true)}>Yes</button> // Picker — each button chains its own value <button onClick={() => submit({ choice: 'A' }).orEnd('A')}>A</button> <button onClick={() => submit({ choice: 'B' }).orEnd('B')}>B</button> Two TypeScript overloads on the mutationFn argument keep the chain out of reach when the handler is non-nullable: required → submit returns void; possibly-undefined → submit returns `{ orEnd(value): void }`. Two exported types describe the shapes: `Trigger<Payload>` (void return) and `ChainTrigger<Payload, Response>` (chain return). When the handler may be undefined and the callsite omits `.orEnd`, the call stays open until something else closes it (the Manual-close path documented in ADR-0014). New tests cover per-callsite fallback (Picker) and the Manual-close path. Bundle still well within budget: 268 B brotli ESM (limit 300), 344 B brotli CJS (limit 400).
1 parent ff5bcd0 commit db6846b

4 files changed

Lines changed: 181 additions & 47 deletions

File tree

.changeset/feat-mutation-flow.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"react-call": minor
33
---
44

5-
- **New `react-call/mutation-flow` subpath entry**`useMutationFlow(call, { mutationFn, fallback? })` is an opt-in hook that wraps the canonical async-submission flow (click → run async → keep open on failure, end on success). The main `react-call` entry stays unchanged: bundle size and API surface of `createCallable` / `CallContext` are not affected. Consumers who never import the subpath pay zero.
5+
- **New `react-call/mutation-flow` subpath entry**`useMutationFlow(call, mutationFn)` is an opt-in hook that wraps the canonical async-submission flow (click → run async → keep open on failure, end on success). The main `react-call` entry stays unchanged: bundle size and API surface of `createCallable` / `CallContext` are not affected. Consumers who never import the subpath pay zero.
66

77
```tsx
88
import { createCallable } from 'react-call'
@@ -12,7 +12,7 @@
1212

1313
export const Confirm = createCallable<Props, boolean>(
1414
({ call, mutationFn }) => {
15-
const submit = useMutationFlow(call, { mutationFn })
15+
const submit = useMutationFlow(call, mutationFn)
1616
return (
1717
<button disabled={submit.pending} onClick={() => submit()}>
1818
Yes
@@ -33,6 +33,10 @@
3333
})
3434
```
3535

36-
The `mutationFn` receives a narrow `MutationCall<Response>` view (`{ end }`) — no `RootProps` ever leaks into the handler's signature. Throws are swallowed by the trigger so the call stays open for retry; the handler decides when (if ever) to `call.end()`. The options object has two overloads: `{ mutationFn }` when the parameter is required, and `{ mutationFn, fallback }` (with `fallback` required by the type system) when `mutationFn` may be undefined. See [ADR-0014](docs/adr/0014-mutation-flow-as-composition-hook.md) for the design rationale and the trade-off versus making this a primitive on `CallContext`.
36+
The `mutationFn` receives a narrow `MutationCall<Response>` view (`{ end }`) — no `RootProps` ever leaks into the handler's signature. Throws are swallowed by the trigger so the call stays open for retry; the handler decides when (if ever) to `call.end()`.
3737

38-
- **Exports**: `MutationFn`, `MutationCall`, `Trigger`, `useMutationFlow` from `react-call/mutation-flow`.
38+
When the `mutationFn` parameter is typed as possibly-undefined, `submit(payload)` returns a chain object whose `.orEnd(value)` closes the call with a fallback at the callsite — `submit().orEnd(true)`. Each button can chain its own value (Picker: `.orEnd('A')` / `.orEnd('B')`). Omitting the chain is also valid: the call stays open until something else closes it.
39+
40+
See [ADR-0014](docs/adr/0014-mutation-flow-as-composition-hook.md) for the design rationale and the trade-off versus making this a primitive on `CallContext`.
41+
42+
- **Exports**: `MutationFn`, `MutationCall`, `Trigger`, `ChainTrigger`, `useMutationFlow` from `react-call/mutation-flow`.

README.md

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ type Props = {
278278

279279
export const Confirm = createCallable<Props, boolean>(
280280
({ call, message, mutationFn }) => {
281-
const submit = useMutationFlow(call, { mutationFn })
281+
const submit = useMutationFlow(call, mutationFn)
282282
return (
283283
<div role="dialog">
284284
<p>{message}</p>
@@ -314,9 +314,10 @@ can retry. The `mutationFn` decides when (if ever) to call `call.end()`.
314314

315315
If your component should still close cleanly when no `mutationFn` was
316316
provided (e.g. a plain "Are you sure?" without any side effect), type the
317-
prop as optional and pass a `fallback` alongside it in the options
318-
object. The type system enforces this — `fallback` is required exactly
319-
when the `mutationFn` parameter may be undefined.
317+
prop as optional and chain `.orEnd(value)` at the submit callsite. When
318+
the `mutationFn` parameter may be undefined, `submit(payload)` returns
319+
a chain object exposing `.orEnd(value)`; when it's provided, the chain
320+
is a no-op and the managed flow runs as normal.
320321

321322
```tsx
322323
type Props = {
@@ -326,17 +327,44 @@ type Props = {
326327

327328
export const Confirm = createCallable<Props, boolean>(
328329
({ call, message, mutationFn }) => {
329-
// ↓ closes with `true` if no mutationFn
330-
const submit = useMutationFlow(call, { mutationFn, fallback: true })
330+
const submit = useMutationFlow(call, mutationFn)
331331
return (
332-
<button disabled={submit.pending} onClick={() => submit()}>
332+
// ↓ closes with `true` if no mutationFn
333+
<button disabled={submit.pending} onClick={() => submit().orEnd(true)}>
333334
Yes
334335
</button>
335336
)
336337
},
337338
)
338339
```
339340

341+
Because the fallback is declared at the callsite (not on the hook), each
342+
button can chain its own value. Useful in pickers where the response is
343+
exactly the option the user picked:
344+
345+
```tsx
346+
type Props = {
347+
mutationFn?: MutationFn<'A' | 'B', { choice: 'A' | 'B' }>
348+
}
349+
350+
export const Picker = createCallable<Props, 'A' | 'B'>(
351+
({ call, mutationFn }) => {
352+
const submit = useMutationFlow(call, mutationFn)
353+
return (
354+
<>
355+
<button onClick={() => submit({ choice: 'A' }).orEnd('A')}>A</button>
356+
<button onClick={() => submit({ choice: 'B' }).orEnd('B')}>B</button>
357+
</>
358+
)
359+
},
360+
)
361+
```
362+
363+
If you'd rather leave the call open when no `mutationFn` was provided —
364+
e.g. let the user dismiss via a "No" button — omit the chain entirely.
365+
`submit()` is a no-op in that case; the dialog stays mounted until
366+
something else closes it.
367+
340368
## Passing a runtime payload
341369

342370
`submit(payload)` forwards a typed payload to the `mutationFn`. Useful
@@ -348,7 +376,7 @@ type Props = {
348376
}
349377

350378
export const Picker = createCallable<Props, string>(({ call, mutationFn }) => {
351-
const submit = useMutationFlow(call, { mutationFn })
379+
const submit = useMutationFlow(call, mutationFn)
352380
return (
353381
<>
354382
<button onClick={() => submit({ choice: 'A' })}>A</button>

packages/react-call/src/__tests__/mutation-flow.test.tsx

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import { type MutationFn, useMutationFlow } from '../mutation-flow'
66
import type * as ReactCall from '../types.public'
77
import { withAct } from './shared/act'
88

9-
// Component fixtures cover the two consumer patterns: a Required-handler
10-
// dialog (no fallback) and an Optional-handler dialog (fallback required
11-
// by the type system because mutationFn may be undefined).
9+
// Component fixtures cover the three consumer patterns:
10+
// - Required handler: submit() runs the mutation; no chain.
11+
// - Optional handler with Fallback response: submit().orEnd(value).
12+
// - Picker with per-callsite fallbacks: each button chains its own
13+
// .orEnd value, aligned with its payload.
1214

1315
type RequiredProps = { mutationFn: MutationFn<boolean> }
1416

@@ -18,7 +20,7 @@ const RequiredComponent: ReactCall.UserComponent<
1820
{}
1921
> = ({ call, mutationFn }) => {
2022
const a11yId = useId()
21-
const submit = useMutationFlow(call, { mutationFn })
23+
const submit = useMutationFlow(call, mutationFn)
2224
return (
2325
<div role="dialog" aria-labelledby={a11yId}>
2426
<p id={a11yId}>Are you sure?</p>
@@ -31,7 +33,11 @@ const RequiredComponent: ReactCall.UserComponent<
3133
>
3234
Yes
3335
</button>
34-
<button type="button" onClick={() => call.end(false)}>
36+
<button
37+
type="button"
38+
data-testid="cancel"
39+
onClick={() => call.end(false)}
40+
>
3541
No
3642
</button>
3743
</div>
@@ -47,14 +53,14 @@ const OptionalComponent: ReactCall.UserComponent<
4753
boolean,
4854
{}
4955
> = ({ call, mutationFn }) => {
50-
const submit = useMutationFlow(call, { mutationFn, fallback: true })
56+
const submit = useMutationFlow(call, mutationFn)
5157
return (
5258
<button
5359
type="button"
5460
data-testid="submit"
5561
data-pending={submit.pending}
5662
disabled={submit.pending}
57-
onClick={() => submit()}
63+
onClick={() => submit().orEnd(true)}
5864
>
5965
Yes
6066
</button>
@@ -69,7 +75,7 @@ const PayloadComponent: ReactCall.UserComponent<PayloadProps, string, {}> = ({
6975
call,
7076
mutationFn,
7177
}) => {
72-
const submit = useMutationFlow(call, { mutationFn })
78+
const submit = useMutationFlow(call, mutationFn)
7379
return (
7480
<>
7581
<button type="button" onClick={() => submit({ choice: 'A' })}>
@@ -84,6 +90,55 @@ const PayloadComponent: ReactCall.UserComponent<PayloadProps, string, {}> = ({
8490

8591
const Payload = createCallable(PayloadComponent)
8692

93+
type PickerProps = {
94+
mutationFn?: MutationFn<'A' | 'B', { choice: 'A' | 'B' }>
95+
}
96+
97+
const PickerComponent: ReactCall.UserComponent<PickerProps, 'A' | 'B', {}> = ({
98+
call,
99+
mutationFn,
100+
}) => {
101+
const submit = useMutationFlow(call, mutationFn)
102+
return (
103+
<>
104+
<button type="button" onClick={() => submit({ choice: 'A' }).orEnd('A')}>
105+
A
106+
</button>
107+
<button type="button" onClick={() => submit({ choice: 'B' }).orEnd('B')}>
108+
B
109+
</button>
110+
</>
111+
)
112+
}
113+
114+
const Picker = createCallable(PickerComponent)
115+
116+
type ManualCloseProps = { mutationFn?: MutationFn<boolean> }
117+
118+
const ManualCloseComponent: ReactCall.UserComponent<
119+
ManualCloseProps,
120+
boolean,
121+
{}
122+
> = ({ call, mutationFn }) => {
123+
const submit = useMutationFlow(call, mutationFn)
124+
return (
125+
<div role="dialog">
126+
<button type="button" data-testid="submit" onClick={() => submit()}>
127+
Yes
128+
</button>
129+
<button
130+
type="button"
131+
data-testid="cancel"
132+
onClick={() => call.end(false)}
133+
>
134+
No
135+
</button>
136+
</div>
137+
)
138+
}
139+
140+
const ManualClose = createCallable(ManualCloseComponent)
141+
87142
describe('useMutationFlow — pending lifecycle', () => {
88143
test('pending flips true while the mutationFn is in-flight, false when it settles', async () => {
89144
let resolveMutation!: () => void
@@ -129,8 +184,8 @@ describe('useMutationFlow — pending lifecycle', () => {
129184
})
130185
})
131186

132-
describe('useMutationFlow — fallback', () => {
133-
test('submit() without a mutationFn closes the call with the fallback response', async () => {
187+
describe('useMutationFlow — fallback via .orEnd', () => {
188+
test('submit().orEnd(value) closes the call with the fallback when no mutationFn was provided', async () => {
134189
render(<Optional />)
135190
const promise = withAct(() => Optional.call({}))
136191

@@ -141,7 +196,7 @@ describe('useMutationFlow — fallback', () => {
141196
await expect(promise).resolves.toBe(true)
142197
})
143198

144-
test('submit() with a mutationFn still runs the managed flow, ignoring the fallback', async () => {
199+
test('submit().orEnd(value) with a mutationFn runs the managed flow and the orEnd value is ignored', async () => {
145200
const mutationFn = vi.fn<MutationFn<boolean>>(async (call) => {
146201
call.end(false)
147202
})
@@ -158,6 +213,48 @@ describe('useMutationFlow — fallback', () => {
158213
})
159214
})
160215

216+
describe('useMutationFlow — per-callsite fallback', () => {
217+
test('the A button chains .orEnd("A")', async () => {
218+
render(<Picker />)
219+
const promise = withAct(() => Picker.call({}))
220+
221+
await act(async () => {
222+
fireEvent.click(screen.getByText('A'))
223+
})
224+
await expect(promise).resolves.toBe('A')
225+
})
226+
227+
test('the B button chains .orEnd("B")', async () => {
228+
render(<Picker />)
229+
const promise = withAct(() => Picker.call({}))
230+
231+
await act(async () => {
232+
fireEvent.click(screen.getByText('B'))
233+
})
234+
await expect(promise).resolves.toBe('B')
235+
})
236+
})
237+
238+
describe('useMutationFlow — Manual-close path', () => {
239+
test('submit() without a chain leaves the call open when no mutationFn was provided', async () => {
240+
render(<ManualClose />)
241+
const promise = withAct(() => ManualClose.call({}))
242+
243+
await act(async () => {
244+
fireEvent.click(screen.getByTestId('submit'))
245+
})
246+
247+
// The call is still open — submit() was a no-op because no mutationFn
248+
// and no .orEnd chain. The dialog waits for the user to close it.
249+
expect(screen.getByRole('dialog')).toBeInTheDocument()
250+
251+
await act(async () => {
252+
fireEvent.click(screen.getByTestId('cancel'))
253+
})
254+
await expect(promise).resolves.toBe(false)
255+
})
256+
})
257+
161258
describe('useMutationFlow — re-entry guard', () => {
162259
test('a second submit() while pending is a no-op', async () => {
163260
let resolveMutation!: () => void

packages/react-call/src/mutation-flow/index.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,49 +21,53 @@ export type MutationFn<Response, Payload = void> = (
2121
) => Promise<void>
2222

2323
/**
24-
* The callable returned by `useMutationFlow`. `pending` reflects the
25-
* in-flight state for the UI; calling the trigger runs the MutationFn
26-
* (or the fallback, when no MutationFn is provided).
24+
* The callable returned by `useMutationFlow` when the MutationFn
25+
* parameter is non-nullable. `pending` reflects the in-flight state for
26+
* the UI; calling the trigger runs the MutationFn.
2727
*/
2828
export type Trigger<Payload> = ((payload: Payload) => void) & {
2929
pending: boolean
3030
}
3131

32-
// Overloads encode the invariant "fallback is required iff mutationFn
33-
// may be undefined". When the consumer types the prop as required, the
34-
// fallback-less options shape applies. When the prop is possibly-
35-
// undefined, TypeScript forces the shape that requires `fallback`. No
36-
// dead-weight optional field leaks into the required-handler case.
32+
/**
33+
* The callable returned by `useMutationFlow` when the MutationFn
34+
* parameter may be undefined. Calling the trigger returns a chain
35+
* object exposing `.orEnd(value)`, which the consumer uses to deliver
36+
* the Fallback response at the callsite. When the MutationFn is
37+
* actually present at runtime, `.orEnd` is a no-op.
38+
*/
39+
export type ChainTrigger<Payload, Response> = ((payload: Payload) => {
40+
orEnd: (value: Response) => void
41+
}) & { pending: boolean }
42+
43+
const NOOP_CHAIN = { orEnd: () => {} }
44+
45+
// Overloads encode the invariant "the Fallback response is reachable
46+
// iff mutationFn may be undefined". Required handler → submit returns
47+
// void. Possibly-undefined handler → submit returns a chain object
48+
// whose `.orEnd(value)` delivers the fallback at the callsite.
3749
export function useMutationFlow<Response, Payload = void>(
3850
call: MutationCall<Response>,
39-
options: { mutationFn: MutationFn<Response, Payload> },
51+
mutationFn: MutationFn<Response, Payload>,
4052
): Trigger<Payload>
4153
export function useMutationFlow<Response, Payload = void>(
4254
call: MutationCall<Response>,
43-
options: {
44-
mutationFn: MutationFn<Response, Payload> | undefined
45-
fallback: Response
46-
},
47-
): Trigger<Payload>
55+
mutationFn: MutationFn<Response, Payload> | undefined,
56+
): ChainTrigger<Payload, Response>
4857
export function useMutationFlow<Response, Payload = void>(
4958
call: MutationCall<Response>,
50-
options: {
51-
mutationFn: MutationFn<Response, Payload> | undefined
52-
fallback?: Response
53-
},
54-
): Trigger<Payload> {
55-
const { mutationFn, fallback } = options
59+
mutationFn: MutationFn<Response, Payload> | undefined,
60+
): ChainTrigger<Payload, Response> {
5661
const [pending, setPending] = useState(false)
5762
// Synchronous re-entry guard. `pending` state alone can't guard against
5863
// a second call dispatched programmatically inside the same event-loop
5964
// turn (state hasn't flushed yet); the ref does.
6065
const inFlightRef = useRef(false)
6166

6267
const trigger = ((payload: Payload) => {
63-
if (inFlightRef.current) return
68+
if (inFlightRef.current) return NOOP_CHAIN
6469
if (!mutationFn) {
65-
call.end(fallback as Response)
66-
return
70+
return { orEnd: (value: Response) => call.end(value) }
6771
}
6872
inFlightRef.current = true
6973
setPending(true)
@@ -73,7 +77,8 @@ export function useMutationFlow<Response, Payload = void>(
7377
inFlightRef.current = false
7478
setPending(false)
7579
})
76-
}) as Trigger<Payload>
80+
return NOOP_CHAIN
81+
}) as ChainTrigger<Payload, Response>
7782
trigger.pending = pending
7883

7984
return trigger

0 commit comments

Comments
 (0)