Skip to content

Commit 2a3b897

Browse files
committed
docs(adr): mutation-flow composition hook (0014) + CONTEXT.md
ADR-0014 records the decision to solve issue #22 via a composition hook in a `react-call/mutation-flow` subpath instead of extending `CallContext` with a `call.pending` / `call.mutate` primitive. CONTEXT.md captures the domain glossary the grilling session produced.
1 parent 1f9a8be commit 2a3b897

2 files changed

Lines changed: 148 additions & 0 deletions

File tree

β€ŽCONTEXT.mdβ€Ž

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# react-call domain
2+
3+
Glossary of the terms used across the library, its docs, and its agent
4+
skills. Terms here are project-specific; general programming concepts
5+
are excluded.
6+
7+
## Language
8+
9+
### Core
10+
11+
**Callable**:
12+
The value returned by `createCallable()`. It is both a React component
13+
(mount it as `<Confirm />`) and a namespace of imperative methods
14+
(`call`, `upsert`, `end`, `update`). One Callable maps to one async UI
15+
pattern.
16+
_Avoid_: Modal, Dialog, Component (those are what a Callable models, not
17+
the thing itself).
18+
19+
**Call**:
20+
A single imperative invocation of a Callable (`Confirm.call({...})`).
21+
Resolves to a `Response` once the call's `end` runs. The same Callable
22+
can have many concurrent calls.
23+
_Avoid_: Invocation, request, instance.
24+
25+
**Root**:
26+
The mounting form of a Callable inside the React tree. Soft-deprecated
27+
alias `Callable.Root`; canonical form is the bare component
28+
(`<Confirm />`). See ADR-0013.
29+
_Avoid_: Provider, Portal, Outlet.
30+
31+
**CallContext**:
32+
The `call` prop the user component receives per active call. Exposes
33+
`end`, `ended`, `key`, `index`, `stackSize`, `root`. Per-call; siblings
34+
in the stack each get their own.
35+
_Avoid_: Props.call, context (React `Context` is something else).
36+
37+
**Stack**:
38+
The ordered list of currently-active calls of a Callable. The Root
39+
renders the stack; newer calls appear later in iteration order.
40+
_Avoid_: Queue, list, history.
41+
42+
**Upsert**:
43+
Singleton-style call semantics. First `upsert()` creates the call,
44+
subsequent `upsert()`s update its props. Returns the same promise across
45+
the singleton's lifetime.
46+
_Avoid_: Toast, singleton (those are use cases, not the operation).
47+
48+
**Caller scope**:
49+
The site where `Confirm.call({...})` is invoked β€” typically a feature
50+
component or a domain handler. The async logic and the response handling
51+
live here.
52+
_Avoid_: Parent, container.
53+
54+
### Mutation flow
55+
56+
**MutationFlow**:
57+
The orchestrated async-submission lifecycle: trigger fires β†’ pending
58+
goes true β†’ caller-supplied async runs β†’ on success the caller closes
59+
the call, on throw the call stays open and pending clears. Provided by
60+
`useMutationFlow` from the subpath entry `react-call/mutation-flow`.
61+
_Avoid_: Submission, async pattern, mutation (bare).
62+
63+
**MutationFn**:
64+
The async handler the caller provides as a prop of the call. Signature
65+
`(call, payload) => Promise<void>`. Owns the side effects and decides
66+
when to close the call.
67+
_Avoid_: Action, onSubmit, mutator, asyncAction.
68+
69+
**MutationCall**:
70+
The minimal view of CallContext that a MutationFn receives β€” currently
71+
just `{ end }`. Narrower than CallContext on purpose, so MutationFn does
72+
not need to know `RootProps`.
73+
_Avoid_: MutationContext (would falsely suggest a separate React
74+
Context), Closer.
75+
76+
**Trigger**:
77+
The callable function returned by `useMutationFlow`. Calling it runs the
78+
MutationFn (or the fallback) while exposing `trigger.pending`. One
79+
trigger per call to `useMutationFlow`.
80+
_Avoid_: Submit, runner, dispatcher.
81+
82+
**Fallback response**:
83+
The Response value used when `submit()` fires but no MutationFn was
84+
provided. Required as the 3rd argument to `useMutationFlow` exactly when
85+
the MutationFn parameter is typed as possibly-undefined.
86+
_Avoid_: Default, no-op.
87+
88+
## Relationships
89+
90+
- A **Callable** produces zero or more **Calls** over its lifetime.
91+
- A **Call** carries one **CallContext**; siblings in the **Stack** each
92+
carry their own.
93+
- A **MutationFlow** is scoped to a single **Call** β€” the **Trigger**
94+
closes over that call's **CallContext**.
95+
- A **MutationFn** lives in **caller scope** but runs against the
96+
**MutationCall** view of the **CallContext**, not the full one.
97+
- The **Fallback response** is only meaningful when the **MutationFn**
98+
may be absent at the **Call** site.
99+
100+
## Example dialogue
101+
102+
> **Maintainer:** "If the **MutationFn** throws, does the **Call** end?"
103+
>
104+
> **Designer:** "No β€” the **Trigger** swallows the throw, **pending**
105+
> clears, the **Call** stays open. The **MutationFn** itself decides
106+
> when to invoke `call.end()`."
107+
>
108+
> **Maintainer:** "And if the caller never provides a **MutationFn**?"
109+
>
110+
> **Designer:** "Then `submit()` closes the **Call** with the
111+
> **Fallback response** β€” but the type signature only lets you omit the
112+
> fallback when the **MutationFn** parameter is non-nullable."
113+
114+
## Flagged ambiguities
115+
116+
- "mutation" used to mean both the **MutationFlow** (the lifecycle) and
117+
the **MutationFn** (the handler). Resolved: **MutationFlow** is the
118+
pattern, **MutationFn** is the function the caller writes.
119+
- "context" risked collision with React's `Context`. Resolved: we use
120+
**CallContext** (the per-call prop bag, not a React `Context`) and
121+
**MutationCall** (the subset the handler sees) β€” the word "context"
122+
alone is avoided in lib API.
123+
- "asyncAction" appears in third-party patterns this design takes
124+
inspiration from. Resolved: the canonical name in react-call is
125+
**MutationFn**.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Async submission is solved by a composition hook in a subpath, not by extending `CallContext`
2+
3+
Issue #22 asks for a first-class answer to *click β†’ run async β†’ keep open on failure / end on success*. We ship `useMutationFlow` from a new subpath entry `react-call/mutation-flow` β€” a hook the user component opts into in its own body β€” and leave `createCallable` and `CallContext` untouched. The competing direction (PR #81) made it a primitive on `CallContext` (`call.pending`, `call.mutate`) with a new `CallOptions` slot on `call()` / `upsert()` and a breaking reorder of `createCallable`'s generics. We chose the hook because it delivers the same UX without touching the lib core, keeps the main bundle strictly under its 1 KB brotli budget, and is structurally additive (consumers who never import the subpath pay zero).
4+
5+
## Considered options
6+
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 encode the invariant "fallback is required iff `mutationFn` is possibly-undefined", so the wart of a dead-weight third arg is closed at compile time. Lib core: zero changes.
9+
- **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+
- **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+
## Consequences
13+
14+
- **`react-call/mutation-flow` is a new public entry.** Mirrors the existing `react-call/vite` subpath pattern. Has its own `dist` build output, its own type definitions, and is independently versioned in `exports`.
15+
- **`createCallable` and `CallContext` are unchanged.** Existing consumers see no API drift; the 1 KB brotli budget on `dist/main.js` and `dist/main.cjs` stays as-is.
16+
- **No new generic on `createCallable`.** `MutationPayload` lives on the hook (`useMutationFlow<Response, Payload>`) β€” granularity is per-trigger rather than per-Callable. Two buttons in the same component can use different payload types.
17+
- **`mutationFn` is a regular prop, not a `CallOptions` slot.** This makes it updatable via `Confirm.update({ mutationFn: newFn })` mid-call (PR #81's `CallOptions` was set-once at `call()` time), and removes the asymmetry between props and options at the call site.
18+
- **`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+
- **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+
- **`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.** `mutationFn` required β†’ 2-arg signature with no fallback; `mutationFn` possibly-undefined β†’ 3-arg signature with fallback required. The dead-weight third arg from the original sketch is gone.
22+
- **`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+
- **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

Comments
Β (0)