feat: async mutation primitive (call.pending + call.mutate)#81
Closed
desko27 wants to merge 3 commits into
Closed
Conversation
Captures the v2 decision to add `call.pending` + `call.mutate(payload)` to the CallContext, with `mutationFn` in a new CallOptions slot. Also adds CONTEXT.md as the project's first domain glossary, documenting the modal/caller/library responsibility split and the concurrency rules. Closes the design grilling for #22; implementation follows in the next commit.
Closes #22 (open since Dec 2024) with a first-class answer to the "accept button → async op → disable while loading → keep open on failure / end on success" flow. See ADR-0014 for the design rationale and considered alternatives. New on CallContext: - call.pending: boolean — in-flight signal for disabled buttons / spinners - call.mutate(payload) — managed-pending trigger that invokes the caller-provided mutationFn, swallows throws (caller's own try/catch handles UI side-effects), and lets the mutationFn decide when to call.end() The mutationFn lives in an optional CallOptions slot — the new 2nd arg of call() / upsert() — not in Props. This keeps the modal's Props strictly UI; the caller closes over domain helpers (mutation hooks, alerts, translations) in mutationFn. Naming follows TanStack Query (mutate / mutationFn / pending) for free familiarity. The single deviation: the modal-supplied data is called "payload", not "variables", because the mutationFn closes over half its inputs in caller scope — what flows through call.mutate(...) is the modal's contribution only, not "all variables" of the mutation. BREAKING: createCallable's generic signature reorders to <Props, Response, MutationPayload = void, RootProps = {}> RootProps moves from 3rd to 4th position; consumers who passed three generics to use RootProps need `void` in the new 3rd slot. v2 umbrella established in ADR-0003 covers the cost. Mechanical migration. Conditional tuple makes call() ergonomic when Props = void — Confirm.call({ mutationFn }) is valid; the options slot promotes to first arg without forcing an `undefined` placeholder. Same flavour as the existing end() / update() targeted/untargeted overloads. Concurrency: - One mutation in-flight per call; re-entrant mutate is no-op + dev warn - call.end() during pending wins; the still-running mutationFn's eventual call.end(value) is a silent no-op - pending is per-call, not per-mutationFn Helper types exported (under both named exports and the ReactCall.* namespace): MutationContext<Response>, MutationFunction<Payload, Response>, CallOptions<Payload, Response>. Bundle budget raised from 1 KB to 1.25 KB brotli — honest reflection of the new functionality. The alternative was crunching the dev warnings (which the consumer's bundler strips for prod anyway). Out of scope for v2 (deliberate, additive later if real demand): no Callable.mutate() from outside, no AbortSignal on MutationContext, no call.error on CallContext. ADR-0014 documents why. Tests: 17 new in mutate.test.tsx covering happy path, error swallow, re-entrancy guard, end-during-pending race, upsert symmetry, missing mutationFn warn, and the void-Props ergonomic form. 94/94 pass; size within new budget; tsc clean.
Two leftover warnings after the feat commit: 1. mutate.test.tsx: `type NoProps = void` was matching the lib's `Props = void` default to exercise the void-Props ergonomic form. biome's `noConfusingVoidType` doesn't know that context — same `biome-ignore` reason is already used in callable-types.test.ts. 2. createCallable/index.tsx: the `storedMutationFn!(...)` non-null assertion was correct (the early-return guard above proves it defined), but TypeScript doesn't narrow the `let` across the closure boundary. Capture into a `const fn` after the guard so the closure sees the narrowed type and no assertion is needed. No behaviour change.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
5 tasks
Owner
Author
|
Close in favor of #82 |
desko27
added a commit
that referenced
this pull request
May 25, 2026
feat: mutation-flow composition hook (lighter alternative to PR #81)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #22 (open since Dec 2024) with a first-class answer to the canonical async-submission flow: click → run async op → disable while loading → keep open on failure / end on success.
Two additions to
CallContext:call.pending: boolean— true while an in-flight mutation hasn't settled.call.mutate(payload)— managed-pending trigger that invokes the caller-providedmutationFn, swallows throws, and lets themutationFndecide when tocall.end().The
mutationFnlives in a new optionalCallOptionsslot — the second arg ofcall()/upsert()— not inProps. Naming follows TanStack Query (mutate/mutationFn/pending) for free familiarity; the one deviation ispayloadinstead ofvariables(themutationFncloses over half its inputs in caller scope, so what flows throughcall.mutate(...)is the modal's contribution only — not "all variables").Full design rationale + considered alternatives in ADR-0014. Grilling session that produced the design is captured in CONTEXT.md.
createCallable's signature reorders to<Props, Response, MutationPayload = void, RootProps = {}>.RootPropsmoves from 3rd to 4th position. Mechanical migration:The (small) set of consumers using the 3rd generic for
RootPropsinsertvoidin the new 3rd slot. Bundle budget raised from 1 KB → 1.25 KB brotli to fit the new primitive honestly (alternative was crunching dev warnings the consumer's bundler strips for prod anyway).Commits
65abf28docs(adr): design decision + CONTEXT.md8846e66feat(react-call): implementation + types + tests + readme + changeset26a6b09chore(react-call): silence two biome warningsOut of scope (deliberate, additive later if real demand)
Callable.mutate(...)from outsideAbortSignalonMutationContextcall.erroronCallContextADR-0014 documents why each was deferred.
Test plan
pnpm vitest run— 94/94 pass (17 new inmutate.test.tsx)pnpm check:types— cleanpnpm lint— clean (2 prior warnings silenced viabiome-ignorewith reasons)pnpm --filter react-call run build+pnpm size— within new 1.25 KB brotli budgetapps/vite/apps/nextjs(not done — these apps don't exercise the mutation path; would be a follow-up if you want aapps/*demo of the pattern)sites/demo(also not done — happy to add aYourMutation.tsxscene in a follow-up)Do NOT merge
Per request, leaving for manual merge.