You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The architectural decision (composition hook in a subpath, not
extending CallContext) is unchanged. What this amendment captures:
- Chosen-option bullet updated to the positional signature with
`submit(payload).orEnd(value)` chain for the Fallback response.
- Options-object signature (the previous iteration in this PR) moved
to Considered options as an explicit rejected alternative, so a
future reader doesn't re-propose it without knowing the trade-off.
- Replaced the obsolete "TypeScript overloads on the options object"
consequence with the new "positional + chain" mechanism, exporting
both `Trigger<Payload>` and `ChainTrigger<Payload, Response>`.
- New consequence: the type system no longer enforces the Fallback
response (regression accepted from grilling Q2).
- New consequence: Fallback response is per-Trigger-callsite, not
per-Callable (Picker-style flexibility from grilling Q3).
- New consequence: the Manual-close path is a supported branch (from
grilling Q6) β `submit()` without a chain is a legitimate pattern.
Copy file name to clipboardExpand all lines: docs/adr/0014-mutation-flow-as-composition-hook.md
+6-2Lines changed: 6 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -5,7 +5,8 @@ Issue #22 asks for a first-class answer to *click β run async β keep open on
5
5
## Considered options
6
6
7
7
-**Primitive on `CallContext` (PR #81 shape).**`call.pending` and `call.mutate(payload)` exposed by the lib itself, with `mutationFn` living in a new `CallOptions` second-arg of `call()` / `upsert()` and `MutationPayload` inserted as the 3rd generic of `createCallable` (RootProps moves to 4th). Rejected: requires ~+159 LOC in `createCallable`, mutates a public type (`CallContext`), bumps the brotli budget 1 KB β 1.25 KB, and forces a BREAKING reorder of generics on consumers who already used `<Props, Response, RootProps>`. The functional win over the hook is marginal: HMR-preserved `pending` across saves and the option to wire `Callable.mutate(...)` from outside later β both rare. Not worth the lib-core surface area.
8
-
-**Composition hook in a subpath (chosen).**`useMutationFlow(call, { mutationFn, fallback? })` lives in `react-call/mutation-flow`. Pending is local `useState` inside the user component. The handler receives a narrow `MutationCall<Response>` view of the call (just `{ end }`) and decides when to close. Two TypeScript overloads on the options object encode the invariant "fallback is required iff `mutationFn` is possibly-undefined", so the wart of a dead-weight optional field is closed at compile time. Lib core: zero changes.
8
+
-**Composition hook in a subpath (chosen).**`useMutationFlow(call, mutationFn)` lives in `react-call/mutation-flow`. Pending is local `useState` inside the user component. The handler receives a narrow `MutationCall<Response>` view of the call (just `{ end }`) and decides when to close. The Fallback response, when needed, is delivered at the Trigger callsite as a method chain: `submit(payload).orEnd(value)`. Lib core: zero changes.
9
+
-**Options-object signature with TS-enforced Fallback response.**`useMutationFlow(call, { mutationFn, fallback? })` with two overloads encoding the invariant "fallback is required iff `mutationFn` is possibly-undefined", so the consumer is forced to declare the fallback statically whenever the prop is typed nullable. Rejected during the PR #82 grilling: the fallback ends up declared in a place disconnected from where the Trigger fires, and the hook can only express one fallback per Callable even when distinct callsites (e.g. a Picker with `.orEnd('A')` / `.orEnd('B')`) naturally pair with distinct response values. Moving the fallback to the Trigger callsite via `.orEnd(value)` co-locates it with the payload and allows per-callsite values, at the cost of losing the static-typing guarantee that the fallback is provided.
9
10
-**Pure docs / no helper.** Just document the orion-style pattern (`useState` + `try/finally` in the user component, `asyncAction` prop with explicit `close`). Rejected: leaves the boilerplate of `useState`/`finally`/no-mutationFn-fallback at every dialog. The user-mentioned `usePending` and "builder for mutationFn" framing made the "no helper" position too thin.
10
11
-**Hook in the main entry instead of a subpath.** One import path, more discoverable, tree-shakeable in modern bundlers. Rejected: the hook is ~100β200 bytes brotli; budgeting it into the main entry would push it past the 1 KB limit and force a budget bump (1 KB β ~1.1 KB). Cheap to keep separate, costly to revisit later if subpath gets adopted and we want to fold it back in.
11
12
@@ -18,6 +19,9 @@ Issue #22 asks for a first-class answer to *click β run async β keep open on
18
19
-**`Callable.mutate(...)` from caller scope is structurally absent**, not deferred. The trigger is local to the user component's render. Adding caller-scope triggering later would require a parallel lib-level channel β it is *not* an additive extension of this hook. PR #81 left this door open at the cost of the primitive-on-CallContext shape; we trade that door for the smaller lib core.
19
20
-**HMR does not preserve `pending` across saves.** Pending lives in component `useState`; a Fast Refresh during an in-flight mutation resets the visible pending flag (the mutation itself continues in background). Acceptable: editing a dialog mid-mutation is exotic.
20
21
-**`MutationCall<Response>` is the public name** of the handler's `call` argument. Chosen over `MutationContext` (which would falsely suggest a React `Context`) and over `Closer` (too narrow once we ever add `signal` / `ended` / etc.).
21
-
-**The hook signature uses TypeScript overloads on the options object.**`mutationFn` required β `{ mutationFn }` (no fallback); `mutationFn` possibly-undefined β `{ mutationFn, fallback }` with fallback required. The dead-weight optional fallback field is gone in the required-handler case.
22
+
-**The hook signature is positional; the Fallback response is delivered via a chain.** Two TypeScript overloads on the `mutationFn` argument: `mutationFn` required β `submit(payload): void`; `mutationFn` possibly-undefined β `submit(payload): { orEnd(value: Response): void }`. The chain object exists only on the possibly-undefined overload, so attempting `.orEnd` on a required-handler Trigger is a compile-time error. Two exported types describe the shapes: `Trigger<Payload>` (void return) and `ChainTrigger<Payload, Response>` (chain return).
23
+
-**The type system no longer enforces the Fallback response.** With the rejected options-object shape, `fallback` was a required field whenever `mutationFn` could be undefined β `pnpm check:types` caught omissions. With the chain shape, calling `submit(payload)` without `.orEnd` is a valid program; if `mutationFn` happens to be undefined at runtime, the Trigger is a silent no-op. The escape hatch is intentional (see Manual-close path below); the trade-off is that an actual *omission* now reaches runtime as "the button does nothing on click" instead of failing at compile time.
24
+
-**The Fallback response is per-Trigger callsite, not per-Callable.** Distinct buttons in the same component can chain distinct `.orEnd` values aligned with their payload (a Picker chains `.orEnd('A')` on the A button, `.orEnd('B')` on the B button). The rejected options-object shape only exposed a single fallback for all callsites within a single Callable.
25
+
-**The Manual-close path is a supported branch, not a footgun.** When `mutationFn` is typed as possibly-undefined and the consumer deliberately omits `.orEnd`, the Call stays open until something else closes it (e.g. a "No" button calling `call.end()` directly). This is a legitimate pattern for dialogs that prefer manual user dismissal when no handler is provided. The same surface-level shape (`submit(payload)` without a chain) covers both the intentional Manual-close and an accidental forgot-to-chain β code review is the only signal that distinguishes them.
22
26
-**`AbortSignal` on `MutationCall` and `submit.error` on the trigger are deferred**, same as PR #81. Additive without breaking change: extending `MutationCall<Response>` and adding an `error` field to the trigger is purely additive type-wise.
23
27
-**The README needs a new section** documenting `react-call/mutation-flow`, distinct from the createCallable basics. The hook isn't part of the "Call your React components" first-impression API; it's an opt-in helper for a specific pattern, and the docs should frame it that way.
0 commit comments