diff --git a/.changeset/feat-async-mutation.md b/.changeset/feat-async-mutation.md new file mode 100644 index 0000000..8f4e3e7 --- /dev/null +++ b/.changeset/feat-async-mutation.md @@ -0,0 +1,69 @@ +--- +"react-call": major +--- + +**Async submission flow as a first-class mutation primitive** (closes #22, see ADR-0014). + +Two additions to `CallContext`: + +- **`call.pending: boolean`** — true while an in-flight mutation hasn't settled. Component reads to disable inputs / show spinners. +- **`call.mutate(payload)`** — managed-pending trigger. Sets `pending` to true, invokes the caller-provided `mutationFn` with the payload, awaits, sets `pending` back to false. Errors thrown by `mutationFn` are swallowed; the call stays open (the caller's own `try/catch` inside `mutationFn` handles any UI side-effects). + +The `mutationFn` lives in a new optional `CallOptions` slot — the second arg of `call()` / `upsert()` — **not** in `Props`. This keeps the modal's Props strictly UI, while the caller closes over domain helpers (mutation hooks, alerts, translations) in `mutationFn`: + +```tsx +type Props = { message: string } +type Payload = { id: string } + +const Confirm = createCallable(({ call, message }) => ( + +

{message}

+ + +
+)) + +Confirm.call( + { message: 'Delete?' }, + { + mutationFn: async (call, { id }) => { + try { + await api.delete(id) + call.end(true) + alert.success(...) + } catch (e) { + alert.error(...) + // no end / no throw → dialog stays open, pending clears + } + }, + }, +) +``` + +**BREAKING — generic reorder.** `createCallable` — `MutationPayload` takes 3rd position and `RootProps` moves to 4th. Consumers who passed three generics to set `RootProps` need to insert `void` in the new 3rd slot. Migration is mechanical: + +```ts +// 1.x / pre-2.0 +createCallable(Component) + +// 2.0 +createCallable(Component) +``` + +When `Props = void`, `Confirm.call()` and `Confirm.call({ mutationFn })` are both valid via a conditional tuple — the options slot promotes to first arg. + +**Concurrency rules** (enforced by the lib): one mutation in-flight per call; re-entrant `call.mutate()` while `pending` is a no-op (with dev warn); `call.end()` invoked directly during a pending mutation ends immediately and the still-running `mutationFn`'s eventual `call.end(value)` is a silent no-op. + +**Dev warnings** (NODE_ENV-gated, stripped from consumer production bundles): `call.mutate()` invoked without a `mutationFn` in CallOptions, and re-entrant mutate while pending. + +**Bundle budget** raised from 1 KB to 1.25 KB brotli to accommodate the new primitive. Honest reflection of new functionality — the alternative was crunching helpful dev warnings to fit a budget that pre-dated the feature. + +**New helper types exported** (under both named exports and the `ReactCall.*` namespace): + +- `MutationContext` — minimal `{ end }` context the `mutationFn` receives as its first arg. +- `MutationFunction` — full signature of the `mutationFn` slot, composable for consumers building typed mutation helpers. +- `CallOptions` — shape of the options bag. + +**Out of scope for this release** (deliberate, additive later if real demand surfaces): `Callable.mutate(...)` from outside, `AbortSignal` on `MutationContext`, `call.error` on `CallContext`. See ADR-0014. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..8f2f2e0 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,134 @@ +# react-call domain + +Glossary of the terms used across the library, its docs, and its agent skills. + +## Async submission flow + +The pattern where the user component triggers an asynchronous operation in +response to a click (typically an "Accept" button), needs to render a +loading state while it runs, and must keep the call open if the operation +fails or end the call if it succeeds. + +The async work itself is owned by the **caller scope** (it's domain logic +— mutations, API calls — that doesn't belong inside a reusable dialog). +The pending UI state is owned by the **user component** (it's a UI +concern — disabled buttons, spinners — that doesn't belong at the call +site). + +react-call's role is to provide call-context primitives that let the +component coordinate the two without re-implementing +`useState`/try-finally per dialog. + +## Mutation + +The async-submission flow is modeled as a **mutation** (terminology +imported from TanStack Query for free familiarity). Two additions +to `CallContext`: + +- **`call.pending: boolean`** — true while an in-flight mutation + hasn't settled. Component reads to disable inputs / show spinners. + +- **`call.mutate(payload)`** — managed-pending trigger. Sets + `pending` to true, invokes the caller-provided `mutationFn` with + the payload, awaits, sets `pending` back to false. Errors thrown + by `mutationFn` are swallowed (the call stays open; the caller's + own `try/catch` inside `mutationFn` handles any UI side-effects). + The `mutationFn` itself decides when (if ever) to call + `call.end()` — the lib does not auto-end based on return value. + +### Where mutationFn lives + +`mutationFn` is **not** a prop. It lives in a separate options +slot on `call()` / `upsert()`: + +```ts +Confirm.call( + { message: 'Delete?' }, // Props + { mutationFn: async (call, payload) => { ... } } // Options slot +) +``` + +The second arg is an extensible **options bag** (`CallOptions`), +not a `mutationFn`-only param. Future call-time options (e.g., the +`unmountOnEnd: false` lifecycle override sketched in issue #22) can +slot in alongside without breaking the signature. `upsert()` +accepts the same options bag for symmetry. + +This keeps the component's Props strictly UI — the modal never +sees the async logic, and the caller never has to add a +"mutationFn" key to a props object that's otherwise pure UI. + +### Exported helper types + +- **`MutationContext`** — the first arg the `mutationFn` + receives. Intentionally minimal: only `end(response)`. `pending` + is meaningless inside `mutationFn` (always true while running); + `ended` is theoretically useful for "bail post-cancel" logic but + marginal — additive later if a real case emerges. Object shape + (not bare `end`) preserves space for future additions + (`signal`, `ended`, …) without breaking the signature. + +- **`MutationFunction`** — full signature of + the `mutationFn` slot. Composable: consumers can declare + reusable mutation fns and pass them into `CallOptions`. + +- **`CallOptions`** — shape of the options + bag (`{ mutationFn?: MutationFunction<...> }`). Re-exported so + consumers can build typed call-site helpers. + +### Generic positioning (BREAKING in v2) + +`createCallable`'s generics reorder to put `MutationPayload` +in 3rd position; `RootProps` moves to 4th: + +```ts +createCallable +``` + +Rationale: `RootProps` is rarely used in practice (most consumers +never customize the Root's accepted props), while `MutationPayload` +is part of the mainline ergonomic flow we're shipping. Putting +the more-used generic earlier minimizes positional verbosity. +Breaking-change cost is borne by the (small) set of consumers +who passed three generics — they re-order one line. + +## Responsibility split + +- **Modal**: decides *when* to mutate and *what payload* to send + (drawn from UI state — form data, which button, etc.). The + payload is the modal's contribution only — not "all variables" + of the mutation. +- **Caller**: defines `mutationFn` in caller scope, closing over + domain helpers (mutation hooks, translations, alert system, …). + Decides what to do with the payload and what value to close the + call with (`call.end(value)` inside `mutationFn`). +- **Library**: manages `pending` lifecycle, swallows throws, warns + in dev when `call.mutate()` fires but no `mutationFn` was + provided in `CallOptions`. + +## What is NOT in v2 + +- `Callable.mutate(...)` from outside. The mental model is + "modal triggers mutate". External triggers don't fit and have + no obvious semantics for "what payload" without the modal. + Additive in a future minor if a real use case appears. +- `AbortSignal` exposed via `MutationContext`. Useful for + long-running mutations the user cancels, but adds API surface; + additive later if demand surfaces. +- `call.error` exposed on `CallContext` after a thrown mutation. + Consumers who want inline error rendering wire it themselves + via the `try/catch` already inside `mutationFn`. Additive + later if a clean lifecycle for "when does the error clear" + emerges. + +## Mutation concurrency rules + +- One mutation in-flight per call. Re-entrant `call.mutate(...)` + while `pending` is true is a no-op (with `console.warn` in dev). +- `call.end()` invoked directly during a pending mutation ends + the call immediately; the still-running `mutationFn`'s eventual + `call.end(value)` is a silent no-op. +- `pending` is per-call, not per-mutationFn — a single mutation + in-flight blocks any other on the same call. +- No `AbortSignal` exposed in v2 (additive in a future minor if + consumer demand surfaces). diff --git a/README.md b/README.md index e2ea394..55eae8c 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,62 @@ const showProgress = async () => { } ``` +## Async mutations + +For the common "click → run async op → close on success / stay on failure" flow, the `call` prop exposes `call.pending` and `call.mutate(payload)`. The async operation itself lives in a `mutationFn` slot you pass at `call()` time — naming and signature follow TanStack Query so the mental model is the same: `mutationFn` is the work, `mutate(payload)` triggers it, `pending` reflects in-flight state. + +```tsx +type Props = { message: string } +type Payload = { id: string } + +export const Confirm = createCallable(({ call, message }) => ( +
+

{message}

+ + +
+)) + +// At the call site, supply the async work in the optional second arg: +Confirm.call( + { message: 'Delete this item?' }, + { + mutationFn: async (call, { id }) => { + try { + await api.delete(id) + call.end(true) + toast.success('Deleted') + } catch (err) { + toast.error(err) + // no end → dialog stays open, pending clears, user can retry + } + }, + }, +) +``` + +The library handles the `pending` lifecycle (true while the `mutationFn` runs, false when it settles) and silently swallows any throw — your own `try/catch` inside `mutationFn` decides whether to close the dialog (`call.end(value)`) or leave it open for a retry. The mutation context (`call` inside `mutationFn`) exposes only `end` to keep the contract minimal. + +**Concurrency rules:** +- Only one mutation can be in-flight per call — re-entrant `call.mutate()` while `pending` is a no-op (with a dev warning). +- `call.end()` invoked directly (e.g. from a Cancel button) during a pending mutation wins; the still-running `mutationFn`'s eventual `call.end(value)` is a silent no-op. + +`upsert()` accepts the same options bag for symmetry: + +```tsx +Toast.upsert( + { message: 'Saving…' }, + { mutationFn: async (call, payload) => { /* … */ } }, +) +``` + +If your component doesn't need a payload (e.g. a confirm with no extra data), the `MutationPayload` generic defaults to `void` and `call.mutate()` takes no args. + # Exit animations To animate the exit of your component when `call.end()` is run, just pass the duration of your animation in milliseconds to createCallable as a second argument: @@ -240,7 +296,8 @@ Root props will be available to your component via `call.root` object. export const Confirm = createCallable< Props, Response, -+ RootProps ++ void, // ← MutationPayload (3rd) — pass void if you don't use mutations ++ RootProps // ← RootProps moved to 4th in v2 (ADR-0014) >(({ call, message }) => ( ... + Hi {call.root.userName}! @@ -313,12 +370,15 @@ import type { ReactCall } from 'react-call' Type | Description --- | --- -ReactCall.Function | The call() method -ReactCall.UpsertFunction | The upsert() method -ReactCall.Context | The call prop in UserComponent -ReactCall.Props | Your props + the call prop -ReactCall.UserComponent | What is passed to createCallable -ReactCall.Callable | What createCallable returns +ReactCall.Function | The call() method +ReactCall.UpsertFunction | The upsert() method +ReactCall.Context | The call prop in UserComponent +ReactCall.Props | Your props + the call prop +ReactCall.UserComponent | What is passed to createCallable +ReactCall.Callable | What createCallable returns +ReactCall.CallOptions | Optional 2nd arg of `call()` / `upsert()` (`{ mutationFn? }`) +ReactCall.MutationContext | The `call` arg the `mutationFn` receives (`{ end }`) +ReactCall.MutationFunction | Full signature of the `mutationFn` slot # Errors diff --git a/docs/adr/0014-async-mutation-primitive.md b/docs/adr/0014-async-mutation-primitive.md new file mode 100644 index 0000000..1497022 --- /dev/null +++ b/docs/adr/0014-async-mutation-primitive.md @@ -0,0 +1,64 @@ +# Async submission flow as a first-class mutation primitive + +v2 introduces `call.pending` + `call.mutate(payload)` on the `CallContext`, plus a `mutationFn` slot in a new `CallOptions` bag passed as the optional second arg to `call()` / `upsert()`. This closes issue #22 (open since Dec 2024) with a built-in answer to the "accept button → async op → disable while loading → stay open on failure / end on success" pattern, replacing the custom `asyncAction`-style boilerplate consumers were rolling per-dialog. + +The shape is deliberately split across two scopes: the **modal** owns the `pending` UI concern and decides *when* and with *what payload* to mutate; the **caller** owns the `mutationFn` (defined in caller scope so it can close over mutation hooks, translations, alert systems, etc.) and decides *what to do* with the payload and *with what value* to close the call (`call.end(value)` inside `mutationFn`). The library glues both: it manages the `pending` lifecycle and swallows throws from `mutationFn` so failures keep the dialog open without consumer plumbing. + +Three load-bearing constraints earned the API its current shape: + +1. **The `mutationFn` is not a prop.** It lives in a separate `CallOptions` slot, not in `Props`. This keeps the component's Props strictly UI — the modal never sees the async logic, and the caller never has to add a `mutationFn` key to an otherwise pure-UI props object. Side benefit: the options bag is extensible (future call-time options like the `unmountOnEnd: false` lifecycle override sketched in issue #22 slot in without breaking the signature). + +2. **`MutationPayload` takes 3rd generic position, demoting `RootProps` to 4th** (breaking under semver, covered by the v2 umbrella in ADR-0003). `RootProps` is rarely used in practice (most consumers never customize the Root); `MutationPayload` is part of the mainline ergonomic flow we're shipping. Putting the more-used generic earlier minimizes positional verbosity for the 99% case. + +3. **Vocabulary aligns with TanStack Query** (`mutate` / `mutationFn` / `pending`) for free familiarity — anyone using TanStack reads the API and recognises the contract instantly. The one deviation: the modal-supplied data is called `payload`, not `variables`. In TanStack the entire mutation setup is co-located (`useMutation({ mutationFn })` + `.mutate(vars)`), so `variables` describes "all inputs". In react-call the `mutationFn` is defined in caller scope where it closes over half the inputs already; what flows through `call.mutate(...)` is *the modal's contribution only*, not "all variables of the mutation". `payload` describes that more honestly. + +The signature uses a conditional tuple so the props arg cleanly disappears when `Props = void` (the second-arg-as-options case shouldn't force consumers to write `Confirm.call(undefined, { mutationFn })`): + +```ts +type CallArgs = + Props extends void + ? [options?: CallOptions] + : [props: Props, options?: CallOptions] +``` + +Same pattern the lib already uses for `end()` and `update()`'s targeted/untargeted overloads — no new typing trick to learn. + +## Considered options + +- **Lower-level primitives only: split `resolve` from `dismiss`** (the original direction sketched in issue #22's first comment, e.g. `Confirm.call(..., { unmountOnEnd: false })` + `Confirm.update(promise, { loading: true })` + `Confirm.unmountResolved(promise)`). Maximally flexible but minimally ergonomic — every consumer rebuilds the same orchestration around the primitives. Rejected: the canonical case ("run X; close on success, stay on failure") accounts for the vast majority of async-submission flows, and shipping anything thinner forces consumers to keep writing the boilerplate the primitive is meant to remove. + +- **Thick auto-end** (`call.endAsync(asyncFn)` where the fn's return value becomes the `end` argument, throw keeps open). Cleanest one-liner for the simple case. Rejected during grilling: it doesn't compose with side-effects the caller wants to run *after* closing (e.g., `alert.success()` post-`end`), and the "throw to stay open" path forced consumers to write `try { ... } catch (e) { alert.error(); throw e }` — the re-throw is awkward. The chosen "manual end via `call.end()` inside `mutationFn`" loses the one-liner but gains the natural flow consumers already write. + +- **Chained config: `Confirm.withMutation(fn).call(props)`.** Aesthetically appealing (separates mutation from props, no magic prop name) but doesn't actually remove the typing constraint that drove the design — `Variables` still has to be known when the component is *defined* for `call.mutate(payload)` to be typed, which means a generic on `createCallable` regardless. Two variants exist: (a) chained sugar over the current Props-less options slot (zero structural gain, just aesthetic + new API surface), or (b) caller-driven payload where `Variables ≡ Props` and the modal calls `call.mutate()` with no args (clean for read-only flows like delete/retry but breaks form modals where the user types data). Rejected (a) because two ways to do the same thing has real DX cost; rejected (b) because the form-modal case is too common to write off. Additive later as a non-breaking variant if a real "read-only mutation" case appears repeatedly. + +- **`mutationFn` as a regular prop** (typed via `Props['mutationFn']`, stripped from the component's view via internal `Omit`, args inferred via mapped type). Zero new generics, type inference is automatic. Rejected mainly on conceptual grounds: the modal's Props should describe its UI contract, not its async behaviour; mixing them muddies "what does this component do" at the type level. The separate options bag earns its keep by keeping that boundary clean even at the cost of one extra generic. + +- **Migrate `createCallable`'s 2nd positional arg (`unmountingDelay`) to an options bag too**, for consistency with the new `CallOptions` bag in `call()`. Considered and explicitly rejected during grilling: v2's changelog is already long enough, the inconsistency is cosmetic, and consumers already pay the `(Component, 300)` cost in their existing 1.x code. If a future minor adds a second `createCallable`-level option, that's the right moment to migrate — not now for symmetry's sake. + +## Out of scope for v2 (deliberate non-decisions) + +These were each considered and explicitly deferred — not "we forgot," but "the v2 ship needs to stay tight, additive later costs nothing": + +- **`Callable.mutate(promise?, payload)` from outside.** Would mirror `Callable.end` / `Callable.update`'s paired outside-trigger pattern. Rejected because the mental model of `mutate` is "the modal user clicked a button"; an external trigger has no obvious answer to "what payload" without the modal's UI state. Additive in a future minor if a real use case appears (e.g., global hotkey "press Cmd+Enter to submit pending dialog"). + +- **`AbortSignal` on `MutationContext`.** Useful for long-running mutations the user cancels mid-flight, but adds API surface and lifecycle complexity. Consumers who need it today can wire their own `AbortController` in the caller scope. Additive in a future minor if demand surfaces. + +- **`call.error` on `CallContext` after a thrown mutation.** Would enable inline error rendering in the modal without the consumer wiring their own state. Rejected because (1) the canonical flow already runs `try/catch` inside `mutationFn` for toast/alert side-effects, and (2) the "when does `call.error` clear" lifecycle question doesn't have an obvious right answer — premature design without a real use case. Additive in a future minor if inline error UI becomes a common ask. + +- **`pending` and `ended` exposed on `MutationContext`** (the first arg the `mutationFn` receives). `pending` is meaningless inside `mutationFn` (always true while running). `ended` is theoretically useful for "bail post-cancel" logic but marginal — the `call.end(value)` after a cancel is already a silent no-op, and side-effects post-cancel (e.g., a stale "deleted!" toast) are cosmetic, not data-corrupting. The minimal `{ end }` shape preserves space for future additions without breaking the signature. + +## Consequences + +- **`CallContext` gains two public properties (`pending`, `mutate`).** Additive to the surface locked by the v2 CallContext cleanup (ADR-0003 / changeset `breaking-call-context-cleanup`); no consumer migration needed for the additions themselves. + +- **`createCallable`'s generic signature reorders** to ``. The (small) set of consumers who passed three generics to use `RootProps` need to add `void` (or `unknown`) in the new 3rd slot, or migrate to the 4th-positional `RootProps`. Migration is mechanical: one line per `createCallable` call site. Documented in the migration guide alongside the other v2 breakings. + +- **`call()` and `upsert()` accept an optional 2nd `CallOptions` arg.** Additive (existing single-arg call sites keep working). The conditional tuple typing makes `Confirm.call({ mutationFn })` valid when `Props = void` — the options bag promotes to the first slot without forcing `undefined` placeholders. + +- **Three helper types exported** (`MutationContext`, `MutationFunction`, `CallOptions`) for consumers building typed mutation helpers or reusable mutation fns. Re-exports under the `ReactCall.*` namespace alias for the namespaced consumer style. + +- **Bundle size**: the new primitives add ~10 LoC (pending state on the call item, `mutate` method on the context, the swallow + warn logic). Stays well within the existing 1 KB budget. + +- **Dev warning**: a `console.warn` fires (NODE_ENV-gated, same pattern as the rest of the lib's dev guards) when `call.mutate(...)` is invoked but no `mutationFn` was provided in `CallOptions`. Trivial cost, saves debugging time for the obvious misuse. + +- **Issue #22 closes** when v2 ships. The discussion thread on the issue includes earlier proposals (`unmountOnEnd: false` + `Confirm.update(promise, { loading })` + `Confirm.unmountResolved(promise)`); this ADR explicitly supersedes those — the chosen shape is simpler, requires no extra outside calls from the caller, and keeps the modal as the natural locus for the UI-state concern. diff --git a/packages/react-call/package.json b/packages/react-call/package.json index b7abcc5..44cb6c1 100644 --- a/packages/react-call/package.json +++ b/packages/react-call/package.json @@ -54,11 +54,11 @@ "size-limit": [ { "path": "dist/main.js", - "limit": "1 KB" + "limit": "1.25 KB" }, { "path": "dist/main.cjs", - "limit": "1 KB" + "limit": "1.25 KB" } ], "devDependencies": { diff --git a/packages/react-call/src/__tests__/call-context.test.tsx b/packages/react-call/src/__tests__/call-context.test.tsx index 6fa252e..38f432a 100644 --- a/packages/react-call/src/__tests__/call-context.test.tsx +++ b/packages/react-call/src/__tests__/call-context.test.tsx @@ -10,10 +10,7 @@ import { withAct } from './shared/act' type Props = { id: string } -const ProbeComponent: ReactCall.UserComponent = ({ - call, - id, -}) => ( +const ProbeComponent: ReactCall.UserComponent = ({ call, id }) => (
`) and for the generic-default behaviour of `createCallable` // itself. Companion file to types.test.ts (which pins CallContext). // +// v2 generic order (ADR-0014): +// createCallable // Why these matter for a v2: the published .d.ts is part of the contract. // A future refactor that accidentally adds a method to Callable, or // widens / breaks one of the generic defaults, would silently change the // consumer's static surface. These tests block that at compile time. describe('Callable<> shape', () => { - const Callable = createCallable<{ x: number }, boolean, { y: string }>( - () => null, - ) + // Explicit 4 generics: . + const Callable = createCallable< + { x: number }, + boolean, + { id: string }, + { y: string } + >(() => null) - test('exactly matches ReactCall.Callable', () => { + test('exactly matches ReactCall.Callable', () => { expectTypeOf(Callable).toEqualTypeOf< - ReactCall.Callable<{ x: number }, boolean, { y: string }> + ReactCall.Callable<{ x: number }, boolean, { id: string }, { y: string }> >() }) - test('call: (props: Props) => Promise', () => { - expectTypeOf(Callable.call).toEqualTypeOf< - (props: { x: number }) => Promise - >() + test('call accepts (props) or (props, options)', () => { + // expectTypeOf-only — no runtime invocation, no Root needed. + expectTypeOf(Callable.call).toBeCallableWith({ x: 1 }) + expectTypeOf(Callable.call).toBeCallableWith( + { x: 1 }, + { + mutationFn: async (call, payload) => { + expectTypeOf(payload).toEqualTypeOf<{ id: string }>() + call.end(true) + }, + }, + ) }) - test('upsert: (props: Props) => Promise', () => { - expectTypeOf(Callable.upsert).toEqualTypeOf< - (props: { x: number }) => Promise - >() + test('upsert accepts (props) or (props, options)', () => { + expectTypeOf(Callable.upsert).toBeCallableWith({ x: 1 }) + expectTypeOf(Callable.upsert).toBeCallableWith( + { x: 2 }, + { mutationFn: (call, _p) => call.end(false) }, + ) }) test('end accepts both targeted and untargeted forms', () => { - // Targeted form: end(promise, response) - expectTypeOf(Callable.end) - .parameter(0) - .toEqualTypeOf | boolean>() - // The two overloads collapse to either: - // [Promise, boolean] (targeted) - // [boolean] (untargeted) - // Each individual call must satisfy ONE of them — type below ensures - // the function accepts both shapes. - Callable.end(true) - Callable.end(Promise.resolve(true), false) + expectTypeOf(Callable.end).toBeCallableWith(true) + expectTypeOf(Callable.end).toBeCallableWith(Promise.resolve(true), false) }) test('update accepts both targeted and untargeted forms', () => { - Callable.update({ x: 1 }) - Callable.update(Promise.resolve(true), { x: 2 }) - expectTypeOf(Callable.update) - .parameter(0) - .toEqualTypeOf | Partial<{ x: number }>>() + expectTypeOf(Callable.update).toBeCallableWith({ x: 1 }) + expectTypeOf(Callable.update).toBeCallableWith(Promise.resolve(true), { + x: 2, + }) }) test('Root is a FunctionComponent of RootProps', () => { @@ -71,31 +77,67 @@ describe('Callable<> shape', () => { describe('createCallable generic defaults', () => { test('Props defaults to void (no props required at call site)', () => { const Voidish = createCallable(() => null) - // biome-ignore lint/suspicious/noConfusingVoidType: matching the lib's actual default (`Props = void`) - expectTypeOf(Voidish.call).toEqualTypeOf<(props: void) => Promise>() - // Compile-only check that `.call()` with no arg is valid: + // call() with no args must compile. expectTypeOf(Voidish.call).toBeCallableWith() }) test('Response defaults to void', () => { const VoidResponse = createCallable<{ x: number }>(() => null) - expectTypeOf(VoidResponse.call).toEqualTypeOf< - (props: { x: number }) => Promise - >() + expectTypeOf(VoidResponse.call).toBeCallableWith({ x: 1 }) + // .end with no value works because Response is void. + expectTypeOf(VoidResponse.end).toBeCallableWith() + }) + + test('MutationPayload defaults to void: call.mutate() needs no args', () => { + // Type-only assertion via a fake component reference. Inside the + // component, `call.mutate` has shape `(payload: void) => void`. + const NoPayload = createCallable<{ x: number }, boolean>(() => null) + expectTypeOf(NoPayload.call).toBeCallableWith({ x: 1 }) }) test('RootProps defaults to {} (Root accepts empty/no props)', () => { const NoRootProps = createCallable<{ x: number }, boolean>(() => null) - // Deprecated alias kept for backwards compat (ADR-0013). expectTypeOf(NoRootProps.Root).toEqualTypeOf>() }) - test('all three generics explicit produce the corresponding Callable<>', () => { - const Explicit = createCallable<{ msg: string }, number, { name: string }>( - () => null, - ) + test('all four generics explicit produce the corresponding Callable<>', () => { + const Explicit = createCallable< + { msg: string }, + number, + { id: string }, + { name: string } + >(() => null) expectTypeOf(Explicit).toEqualTypeOf< - ReactCall.Callable<{ msg: string }, number, { name: string }> + ReactCall.Callable< + { msg: string }, + number, + { id: string }, + { name: string } + > >() }) }) + +describe('CallOptions ergonomic forms', () => { + test('Props = void allows call(options) — options promotes to 1st arg', () => { + const NoProps = createCallable(() => null) + expectTypeOf(NoProps.call).toBeCallableWith() + expectTypeOf(NoProps.call).toBeCallableWith({ + mutationFn: async (call, payload) => { + expectTypeOf(payload).toEqualTypeOf<{ id: string }>() + call.end(true) + }, + }) + }) + + test('Props != void requires (props, options?) — options cannot replace props', () => { + const WithProps = createCallable<{ x: number }, boolean, { id: string }>( + () => null, + ) + expectTypeOf(WithProps.call).toBeCallableWith({ x: 1 }) + expectTypeOf(WithProps.call).toBeCallableWith( + { x: 1 }, + { mutationFn: async (call, _p) => call.end(true) }, + ) + }) +}) diff --git a/packages/react-call/src/__tests__/exit-animations.test.tsx b/packages/react-call/src/__tests__/exit-animations.test.tsx index a06c53e..85a534a 100644 --- a/packages/react-call/src/__tests__/exit-animations.test.tsx +++ b/packages/react-call/src/__tests__/exit-animations.test.tsx @@ -17,7 +17,7 @@ const UNMOUNTING_DELAY = 50 type Props = { message: string } -const SlowConfirmComponent: ReactCall.UserComponent = ({ +const SlowConfirmComponent: ReactCall.UserComponent = ({ call, message, }) => ( diff --git a/packages/react-call/src/__tests__/isolation.test.tsx b/packages/react-call/src/__tests__/isolation.test.tsx index 0c254c8..b82047f 100644 --- a/packages/react-call/src/__tests__/isolation.test.tsx +++ b/packages/react-call/src/__tests__/isolation.test.tsx @@ -18,7 +18,7 @@ import { withAct } from './shared/act' type Props = { message: string } const dialog = - (instanceLabel: string): ReactCall.UserComponent => + (instanceLabel: string): ReactCall.UserComponent => ({ call, message }) => (
= ({ + call, + message, +}) => ( +
+

{message}

+ + +
+) + +const Form = createCallable(FormComponent) + +// Void-payload variant for the "no payload" ergonomic path. +type VoidPayloadProps = { message: string } +const VoidPayloadComponent: ReactCall.UserComponent< + VoidPayloadProps, + boolean +> = ({ call, message }) => ( +
+ +
+) + +const VoidPayload = createCallable( + VoidPayloadComponent, +) + +describe('call.mutate() — happy path', () => { + test('sets pending=true during async mutationFn, then resets when it resolves', async () => { + const user = userEvent.setup() + render(
) + + let release!: () => void + const inFlight = new Promise((r) => { + release = r + }) + + const promise = withAct(() => + Form.call( + { message: 'go' }, + { + mutationFn: async (call, payload) => { + await inFlight + call.end(payload.id === 'x-123') + }, + }, + ), + ) + + expect(screen.getByRole('dialog').dataset.pending).toBe('false') + + await user.click(screen.getByRole('button', { name: /submit/i })) + expect(screen.getByRole('dialog').dataset.pending).toBe('true') + + withAct(() => release()) + expect(await promise).toBe(true) + }) + + test('mutationFn receives the payload the component passed to call.mutate', async () => { + const user = userEvent.setup() + render() + const seen: Payload[] = [] + + const promise = withAct(() => + Form.call( + { message: 'go' }, + { + mutationFn: async (call, payload) => { + seen.push(payload) + call.end(true) + }, + }, + ), + ) + + await user.click(screen.getByRole('button', { name: /submit/i })) + await promise + expect(seen).toEqual([{ id: 'x-123' }]) + }) + + test('void MutationPayload allows call.mutate() with no args', async () => { + const user = userEvent.setup() + render() + let invocations = 0 + + const promise = withAct(() => + VoidPayload.call( + { message: 'go' }, + { + mutationFn: (call) => { + invocations++ + call.end(true) + }, + }, + ), + ) + + await user.click(screen.getByRole('button', { name: /go/i })) + expect(await promise).toBe(true) + expect(invocations).toBe(1) + }) +}) + +describe('call.mutate() — error handling', () => { + test('throw in mutationFn keeps the dialog open and clears pending', async () => { + const user = userEvent.setup() + render() + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + withAct(() => + Form.call( + { message: 'go' }, + { + mutationFn: async () => { + throw new Error('nope') + }, + }, + ), + ) + + await user.click(screen.getByRole('button', { name: /submit/i })) + + // After the rejected microtask flushes, pending resets and dialog stays. + await waitFor(() => { + expect(screen.getByRole('dialog').dataset.pending).toBe('false') + }) + expect(screen.getByRole('dialog')).toBeInTheDocument() + + errorSpy.mockRestore() + }) + + test('synchronous throw in mutationFn behaves the same as async throw', async () => { + const user = userEvent.setup() + render() + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + withAct(() => + Form.call( + { message: 'go' }, + { + mutationFn: () => { + throw new Error('sync nope') + }, + }, + ), + ) + + await user.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => { + expect(screen.getByRole('dialog').dataset.pending).toBe('false') + }) + expect(screen.getByRole('dialog')).toBeInTheDocument() + + errorSpy.mockRestore() + }) +}) + +describe('call.mutate() — concurrency rules', () => { + test('re-entrant mutate while pending is a no-op and warns in dev', async () => { + // Fixture intentionally omits `disabled={call.pending}` so userEvent + // can fire a second click on the still-enabled button — the test + // covers the LIBRARY's re-entrancy guard, not the consumer's + // `disabled` attribute (which is the recommended UX defence). + type LeakyProps = { message: string } + const LeakyComp: ReactCall.UserComponent< + LeakyProps, + boolean, + Payload, + {} + > = ({ call, message }) => ( +
+ +
+ ) + const Leaky = createCallable(LeakyComp) + + const user = userEvent.setup() + render() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + let release!: () => void + const inFlight = new Promise((r) => { + release = r + }) + let invocations = 0 + + const promise = withAct(() => + Leaky.call( + { message: 'go' }, + { + mutationFn: async (call) => { + invocations++ + await inFlight + call.end(true) + }, + }, + ), + ) + + const button = screen.getByRole('button', { name: /submit/i }) + + // First click starts the mutation (pending → true). + await user.click(button) + // Second click while pending — must be a no-op + dev warn. + await user.click(button) + // Third for good measure. + await user.click(button) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('a mutation is already pending'), + ) + // mutationFn only ran once despite three clicks. + expect(invocations).toBe(1) + + release() + expect(await promise).toBe(true) + warnSpy.mockRestore() + }) + + test('call.end() during a pending mutation ends immediately; mutationFns later end is a silent no-op', async () => { + const user = userEvent.setup() + render() + + let release!: () => void + const inFlight = new Promise((r) => { + release = r + }) + + const promise = withAct(() => + Form.call( + { message: 'go' }, + { + mutationFn: async (call) => { + await inFlight + // This end fires AFTER the external end below. Silent no-op. + call.end(true) + }, + }, + ), + ) + + await user.click(screen.getByRole('button', { name: /submit/i })) + expect(screen.getByRole('dialog').dataset.pending).toBe('true') + + // External cancel wins. + withAct(() => Form.end(promise, false)) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + expect(await promise).toBe(false) + + // Let the still-running mutationFn complete; its `call.end(true)` + // must NOT change the already-resolved value. + release() + expect(await promise).toBe(false) + }) +}) + +describe('call.mutate() — missing mutationFn', () => { + test('warns in dev and is a no-op when no mutationFn was provided in CallOptions', async () => { + const user = userEvent.setup() + render() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + withAct(() => Form.call({ message: 'no fn' })) + await user.click(screen.getByRole('button', { name: /submit/i })) + + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('no mutationFn was provided'), + ) + + warnSpy.mockRestore() + }) +}) + +describe('CallOptions plumbing', () => { + test('upsert() also accepts the options bag and runs the latest mutationFn', async () => { + const user = userEvent.setup() + render() + + const seen: string[] = [] + + withAct(() => + Form.upsert( + { message: 'first' }, + { + mutationFn: async (call) => { + seen.push('first-fn') + call.end(true) + }, + }, + ), + ) + + // Second upsert overrides the mutationFn. + withAct(() => + Form.upsert( + { message: 'second' }, + { + mutationFn: async (call) => { + seen.push('second-fn') + call.end(true) + }, + }, + ), + ) + + await user.click(screen.getByRole('button', { name: /submit/i })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + expect(seen).toEqual(['second-fn']) + }) + + test('upsert() without options preserves the previously-set mutationFn', async () => { + const user = userEvent.setup() + render() + const seen: string[] = [] + + withAct(() => + Form.upsert( + { message: 'first' }, + { + mutationFn: async (call) => { + seen.push('first-fn') + call.end(true) + }, + }, + ), + ) + + // Second upsert without options — should keep the original mutationFn. + withAct(() => Form.upsert({ message: 'second' })) + + await user.click(screen.getByRole('button', { name: /submit/i })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + expect(seen).toEqual(['first-fn']) + }) + + test('void Props: call(options) form (no props arg) routes options correctly', async () => { + // biome-ignore lint/suspicious/noConfusingVoidType: matching the lib's `Props = void` default to exercise the void-Props ergonomic form + type NoProps = void + const NoPropsComp: ReactCall.UserComponent< + NoProps, + boolean, + Payload, + {} + > = ({ call }) => ( +
+ +
+ ) + const NoPropsCallable = createCallable( + NoPropsComp, + ) + + const user = userEvent.setup() + render() + const seen: Payload[] = [] + + const promise = withAct(() => + NoPropsCallable.call({ + mutationFn: async (call, payload) => { + seen.push(payload) + call.end(true) + }, + }), + ) + + await user.click(screen.getByRole('button', { name: /submit/i })) + expect(await promise).toBe(true) + expect(seen).toEqual([{ id: 'noprops' }]) + }) +}) diff --git a/packages/react-call/src/__tests__/root-props.test.tsx b/packages/react-call/src/__tests__/root-props.test.tsx index ca94d4c..5589819 100644 --- a/packages/react-call/src/__tests__/root-props.test.tsx +++ b/packages/react-call/src/__tests__/root-props.test.tsx @@ -12,10 +12,15 @@ import { withAct } from './shared/act' type Props = { message: string } type RootProps = { userName: string } -const GreeterComponent: ReactCall.UserComponent = ({ - call, - message, -}) => ( +// v2 BREAKING (ADR-0014): RootProps moved from 3rd to 4th generic +// position. `void` in the 3rd slot is the MutationPayload default for +// callables that don't use the mutation primitive. +const GreeterComponent: ReactCall.UserComponent< + Props, + void, + void, + RootProps +> = ({ call, message }) => (

Hi {call.root.userName}!

{message}

diff --git a/packages/react-call/src/__tests__/shared/Confirm.tsx b/packages/react-call/src/__tests__/shared/Confirm.tsx index ac51867..6cd43cb 100644 --- a/packages/react-call/src/__tests__/shared/Confirm.tsx +++ b/packages/react-call/src/__tests__/shared/Confirm.tsx @@ -4,7 +4,7 @@ import type * as ReactCall from '../../types.public' type Props = { message: string } -export const ConfirmComponent: ReactCall.UserComponent = ({ +export const ConfirmComponent: ReactCall.UserComponent = ({ call, message, }) => { diff --git a/packages/react-call/src/__tests__/types.test.ts b/packages/react-call/src/__tests__/types.test.ts index 698a748..0648655 100644 --- a/packages/react-call/src/__tests__/types.test.ts +++ b/packages/react-call/src/__tests__/types.test.ts @@ -6,20 +6,28 @@ import type * as ReactCall from '../types.public' // blacklist approach that quietly let `promise`, `resolve`, and the // internal `isUpsert?` flag through to the consumer-visible `call` // prop. Dev rebuilt `CallContext` on top of a `CallItemPublicProperties` -// allowlist (key, end, ended) plus root/index/stackSize — these tests -// pin that contract so a future refactor cannot accidentally re-leak -// the internals through type widening. +// allowlist plus root/index/stackSize — these tests pin that contract +// so a future refactor cannot accidentally re-leak the internals +// through type widening. +// +// v2 also adds `pending` and `mutate` (ADR-0014) — those are +// asserted as required public properties below. -type Ctx = ReactCall.Context<{ message: string }, boolean, { userName: string }> +// Generic order is +// (ADR-0014 reorder: RootProps moved from 3rd to 4th). +type Ctx = ReactCall.Context< + { message: string }, + boolean, + { id: string }, + { userName: string } +> // vitest 4 / expect-type requires a runtime value argument; the cast is -// a placeholder — expectTypeOf only inspects the static type. An empty -// object literal is enough at runtime since the property accesses below -// (ctx.key, ctx.end, …) are typeof-only inside expectTypeOf's overloads. +// a placeholder — expectTypeOf only inspects the static type. const ctx = {} as Ctx describe('CallContext public contract', () => { - test('exposes the six documented public fields with the right types', () => { + test('exposes the documented public fields with the right types', () => { expectTypeOf(ctx.key).toEqualTypeOf() expectTypeOf(ctx.end).toEqualTypeOf<(response: boolean) => void>() expectTypeOf(ctx.ended).toEqualTypeOf() @@ -28,6 +36,11 @@ describe('CallContext public contract', () => { expectTypeOf(ctx.stackSize).toEqualTypeOf() }) + test('exposes the v2 mutation primitive fields', () => { + expectTypeOf(ctx.pending).toEqualTypeOf() + expectTypeOf(ctx.mutate).toEqualTypeOf<(payload: { id: string }) => void>() + }) + test('does not leak the Promise lifecycle internals', () => { expectTypeOf(ctx).not.toHaveProperty('promise') expectTypeOf(ctx).not.toHaveProperty('resolve') @@ -37,10 +50,40 @@ describe('CallContext public contract', () => { expectTypeOf(ctx).not.toHaveProperty('isUpsert') }) + test('does not leak the stored mutationFn', () => { + // mutationFn is part of the call item but must not surface in the + // public CallContext — consumer components are the modal, not the + // caller, and the modal does not need to invoke or even see the fn. + expectTypeOf(ctx).not.toHaveProperty('mutationFn') + }) + test('does not leak the original call props', () => { - // `props` is the user's input that the lib uses to drive the render; - // the consumer's component receives them as actual JSX props, not - // re-bundled inside `call`. expectTypeOf(ctx).not.toHaveProperty('props') }) }) + +describe('MutationContext public contract', () => { + type Mctx = ReactCall.MutationContext + const mctx = {} as Mctx + + test('exposes only `end` — pending/ended deliberately excluded', () => { + expectTypeOf(mctx.end).toEqualTypeOf<(response: boolean) => void>() + expectTypeOf(mctx).not.toHaveProperty('pending') + expectTypeOf(mctx).not.toHaveProperty('ended') + }) +}) + +describe('CallOptions public contract', () => { + type Opts = ReactCall.CallOptions<{ id: string }, boolean> + const opts = {} as Opts + + test('mutationFn is optional and typed with the (call, payload) signature', () => { + expectTypeOf(opts.mutationFn).toEqualTypeOf< + | (( + call: ReactCall.MutationContext, + payload: { id: string }, + ) => Promise | void) + | undefined + >() + }) +}) diff --git a/packages/react-call/src/createCallable/index.tsx b/packages/react-call/src/createCallable/index.tsx index 65cfb5d..c46b50b 100644 --- a/packages/react-call/src/createCallable/index.tsx +++ b/packages/react-call/src/createCallable/index.tsx @@ -2,9 +2,12 @@ import { type FunctionComponent, useSyncExternalStore } from 'react' import { createStackStore } from './store' import type { Resolve } from './types.private' import type { - UserComponent as UserComponentType, - Callable, CallContext, + Callable, + CallOptions, + MutationContext, + MutationFunction, + UserComponent as UserComponentType, } from './types.public' // HMR persistence registry (see ADR-0009, ADR-0010, ADR-0011). Keyed @@ -24,22 +27,55 @@ import type { const storeRegistry = /* @__PURE__ */ new Map< string, // biome-ignore lint/suspicious/noExplicitAny: registry values are heterogeneous by design - ReturnType> + ReturnType> >() -export function createCallable( - UserComponent: UserComponentType, +// Runtime discriminator for the optional `CallOptions` slot in `call()` +// / `upsert()`. The conditional tuple in the public types makes +// `Confirm.call({ mutationFn })` valid when `Props = void`, but at +// runtime we can't see whether Props is void — so we peel the LAST +// arg as options if it carries the reserved `mutationFn` key. +// Consumers are documented not to use `mutationFn` as a key in their +// own Props (it's reserved for the options bag). +const isOptionsLike = (x: unknown): boolean => + x !== null && typeof x === 'object' && 'mutationFn' in (x as object) + +export function createCallable< + Props = void, + Response = void, + MutationPayload = void, + RootProps = {}, +>( + UserComponent: UserComponentType, unmountingDelay = 0, -): Callable { +): Callable { const storeRef: { - current: ReturnType> - } = { current: createStackStore() } + current: ReturnType< + typeof createStackStore + > + } = { current: createStackStore() } + + const splitArgs = ( + args: unknown[], + ): { + props: Props | undefined + options: CallOptions | undefined + } => { + const lastArg = args[args.length - 1] + if (args.length > 0 && isOptionsLike(lastArg)) { + const options = lastArg as CallOptions + return args.length === 1 + ? { props: undefined, options } + : { props: args[0] as Props, options } + } + return { props: args[0] as Props, options: undefined } + } const createEnd = (promise: Promise | null) => (response: Response) => { storeRef.current.set(promise, (call) => { call.resolve(response) - return { ...call, ended: true } + return { ...call, ended: true, pending: false } }) globalThis.setTimeout( () => storeRef.current.remove(promise), @@ -47,6 +83,70 @@ export function createCallable( ) } + const createMutate = + (promise: Promise) => + (payload: MutationPayload): void => { + let alreadyPending = false + let alreadyEnded = false + let storedMutationFn: + | MutationFunction + | undefined + + // Atomic read-and-conditionally-set: peek pending/ended/mutationFn + // and flip pending to true in one pass through the stack mapper. + storeRef.current.set(promise, (c) => { + alreadyPending = c.pending + alreadyEnded = c.ended + storedMutationFn = c.mutationFn + if (c.pending || c.ended || !c.mutationFn) return c + return { ...c, pending: true } + }) + + if (alreadyEnded) return // silent bail — call already closed + + if (!storedMutationFn) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + 'react-call:call.mutate() invoked but no mutationFn was provided in CallOptions. No-op.', + ) + } + return + } + + if (alreadyPending) { + if (process.env.NODE_ENV !== 'production') { + console.warn( + 'react-call:call.mutate() invoked while a mutation is already pending for this call. No-op.', + ) + } + return + } + + // Capture the narrowed value into a const so the closure can + // call it without a non-null assertion — the early-return guard + // above proved storedMutationFn is defined. + const fn = storedMutationFn + const mutationContext: MutationContext = { + end: createEnd(promise), + } + + Promise.resolve() + .then(() => fn(mutationContext, payload)) + .catch(() => { + // Swallow — the caller's own try/catch inside mutationFn handles + // UI side-effects (toasts, alerts, etc.). The dialog stays open + // unless `call.end()` was invoked inside the mutationFn. + }) + .finally(() => { + // Reset pending only if the call hasn't ended in the meantime. + // (If `call.end()` was called inside mutationFn, ended=true and + // pending was already reset by createEnd.) + storeRef.current.set(promise, (c) => + c.ended ? c : { ...c, pending: false }, + ) + }) + } + const assertSingleRoot = () => { const listenersSize = storeRef.current.getListenersSize() if (!listenersSize) throw new Error('No found!') @@ -54,8 +154,9 @@ export function createCallable( throw new Error('Multiple instances of found!') } - const call: Callable['call'] = (props) => { + const call = ((...args: unknown[]) => { assertSingleRoot() + const { props, options } = splitArgs(args) let resolve!: Resolve const promise = new Promise((res) => { @@ -63,22 +164,33 @@ export function createCallable( }) storeRef.current.add({ - props, + props: props as Props, end: createEnd(promise), ended: false, + pending: false, + mutate: createMutate(promise), + mutationFn: options?.mutationFn, promise, resolve, }) return promise - } + }) as Callable['call'] - const upsert: Callable['upsert'] = (props) => { + const upsert = ((...args: unknown[]) => { assertSingleRoot() + const { props, options } = splitArgs(args) const existing = storeRef.current.getUpsertPromise() if (existing) { - storeRef.current.set(existing, (c) => ({ ...c, props })) + storeRef.current.set(existing, (c) => ({ + ...c, + props: props as Props, + // Preserve previous mutationFn when this upsert call didn't + // supply options; explicitly override (even with undefined) + // when it did. + mutationFn: options ? options.mutationFn : c.mutationFn, + })) return existing } @@ -89,20 +201,23 @@ export function createCallable( storeRef.current.setUpsertPromise(promise) storeRef.current.add({ - props, + props: props as Props, end: (response: Response) => { storeRef.current.setUpsertPromise(null) createEnd(promise)(response) }, ended: false, + pending: false, + mutate: createMutate(promise), + mutationFn: options?.mutationFn, promise, resolve, }) return promise - } + }) as Callable['upsert'] - const end: Callable['end'] = ( + const end: Callable['end'] = ( ...args: [Promise, Response] | [Response] ) => { const targeted = args.length === 2 @@ -115,7 +230,12 @@ export function createCallable( return createEnd(promise)(response) } - const update: Callable['update'] = ( + const update: Callable< + Props, + Response, + MutationPayload, + RootProps + >['update'] = ( ...args: [Promise, Partial] | [Partial] ) => { const targeted = args.length === 2 @@ -130,22 +250,26 @@ export function createCallable( storeRef.current.subscribe, storeRef.current.getSnapshot, storeRef.current.getServerSnapshot, - ).map(({ props, key, end: callEnd, ended }, index, stack) => ( - - } - /> - )) + ).map( + ({ props, key, end: callEnd, ended, pending, mutate }, index, stack) => ( + + } + /> + ), + ) } const callable = Object.assign(Root, { @@ -178,7 +302,7 @@ export function createCallable( const existing = storeRegistry.get(value) if (existing) { storeRef.current = existing as ReturnType< - typeof createStackStore + typeof createStackStore > } else { storeRegistry.set(value, storeRef.current) diff --git a/packages/react-call/src/createCallable/store.ts b/packages/react-call/src/createCallable/store.ts index 8deb1af..d4053c0 100644 --- a/packages/react-call/src/createCallable/store.ts +++ b/packages/react-call/src/createCallable/store.ts @@ -1,47 +1,56 @@ import type { Resolve } from './types.private' +import type { MutationFunction } from './types.public' -type Stack = CallItem[] -type Listener = (stack: Stack) => void +type Stack = CallItem< + Props, + Response, + MutationPayload +>[] +type Listener = ( + stack: Stack, +) => void -type CallItem = CallItemPublicProperties & { +type CallItem = CallItemPublicProperties< + Props, + Response, + MutationPayload +> & { props: Props promise: Promise resolve: Resolve + mutationFn?: MutationFunction } -export type CallItemPublicProperties<_, Response> = { +export type CallItemPublicProperties<_Props, Response, MutationPayload> = { key: string end: (response: Response) => void ended: boolean + pending: boolean + mutate: (payload: MutationPayload) => void } -// React's useSyncExternalStore compares snapshots with Object.is and -// throws "The result of getServerSnapshot should be cached to avoid an -// infinite loop" if the function returns a fresh value every call. -// A single per-store stable reference is enough — on the server the -// stack is always empty (no `call()` can run before hydration), and -// hydration switches the hook to `getSnapshot` immediately. Surfaced -// by the apps/nextjs playground; Vite CSR never hit this path. -const EMPTY_STACK: Stack = [] +const EMPTY_STACK: Stack = [] -export function createStackStore() { +export function createStackStore() { let nextKey = 0 - let stack: Stack = [] + let stack: Stack = [] let upsertPromise: Promise | null = null - const listeners: Set> = new Set() + const listeners: Set> = new Set() const emitChange = () => { for (const listener of listeners) listener(stack) } return { - add: (call: Omit, 'key'>) => { + add: (call: Omit, 'key'>) => { stack = [...stack, { ...call, key: String(nextKey++) }] emitChange() }, set: ( promise: Promise | null, - updateFn: (call: CallItem) => CallItem, + updateFn: ( + call: CallItem, + ) => CallItem, ) => { stack = stack.map((call) => promise && call.promise !== promise ? call : updateFn(call), @@ -52,7 +61,7 @@ export function createStackStore() { stack = stack.filter((c) => promise && c.promise !== promise) emitChange() }, - subscribe: (listener: Listener) => { + subscribe: (listener: Listener) => { listeners.add(listener) return () => { @@ -65,7 +74,8 @@ export function createStackStore() { } }, getSnapshot: () => stack, - getServerSnapshot: () => EMPTY_STACK as Stack, + getServerSnapshot: () => + EMPTY_STACK as Stack, getListenersSize: () => listeners.size, getUpsertPromise: () => upsertPromise, setUpsertPromise: (p: Promise | null) => { diff --git a/packages/react-call/src/createCallable/types.public.ts b/packages/react-call/src/createCallable/types.public.ts index ffa4740..38ea058 100644 --- a/packages/react-call/src/createCallable/types.public.ts +++ b/packages/react-call/src/createCallable/types.public.ts @@ -1,25 +1,73 @@ import type { FunctionComponent } from 'react' import type { CallItemPublicProperties } from './store' +/** + * Narrow context passed to a `mutationFn` as its first arg. Kept + * deliberately minimal (only `end`) — `pending` is always true while + * the mutationFn runs, and `ended` is a marginal "bail after cancel" + * signal that is additive if a real case appears. + * + * Object shape (not bare `end`) preserves space for future additions + * (`signal`, `ended`, …) without breaking the public signature. + */ +export type MutationContext = { + end: (response: Response) => void +} + +/** + * The shape of a `mutationFn` slot in `CallOptions`. Authored in + * caller scope; closes over domain helpers (mutation hooks, alert + * system, translations, …). The `payload` is only the modal's + * contribution; "all variables" of the mutation come partly from + * the closure. + */ +export type MutationFunction = ( + call: MutationContext, + payload: MutationPayload, +) => Promise | void + +/** + * Options bag accepted as the optional second arg of `call()` and + * `upsert()`. Extensible for future call-time options without + * breaking the signature. + */ +export type CallOptions = { + mutationFn?: MutationFunction +} + +/** + * Conditional tuple that hides the `props` slot when `Props = void`, + * so consumers can write `Confirm.call({ mutationFn })` instead of + * `Confirm.call(undefined, { mutationFn })`. Same flavour as the + * tuple-union overloads on `end()` and `update()`. + */ +type CallArgs = Props extends void + ? [options?: CallOptions] + : [props: Props, options?: CallOptions] + /** * The call() method */ -export type CallFunction = (props: Props) => Promise +export type CallFunction = ( + ...args: CallArgs +) => Promise /** * The upsert() method */ -export type UpsertFunction = ( - props: Props, +export type UpsertFunction = ( + ...args: CallArgs ) => Promise /** * The special call prop in UserComponent */ -export type CallContext = CallItemPublicProperties< +export type CallContext< Props, - Response -> & { + Response, + MutationPayload = void, + RootProps = {}, +> = CallItemPublicProperties & { root: RootProps index: number stackSize: number @@ -28,15 +76,25 @@ export type CallContext = CallItemPublicProperties< /** * User props + the call prop */ -export type PropsWithCall = Props & { - call: CallContext +export type PropsWithCall< + Props, + Response, + MutationPayload = void, + RootProps = {}, +> = Props & { + call: CallContext } /** * What is passed to createCallable */ -export type UserComponent = FunctionComponent< - PropsWithCall +export type UserComponent< + Props, + Response, + MutationPayload = void, + RootProps = {}, +> = FunctionComponent< + PropsWithCall > /** @@ -46,19 +104,29 @@ export type UserComponent = FunctionComponent< * and use the imperative methods (`call`, `upsert`, `end`, `update`) as * properties on the same function. This dual shape makes the export * Fast-Refresh-compatible under vite-plugin-react. See ADR-0009. + * + * Generic positions (v2 BREAKING — see ADR-0014): + * + * + * `MutationPayload` sits in 3rd position because it's part of the + * mainline mutation flow; `RootProps` (rarely customised) moves to 4th. */ -export type Callable = - FunctionComponent & { - /** - * @deprecated Use `` directly — `Confirm.Root === Confirm`. - * Kept as an alias for backwards compatibility; no removal date. - * See ADR-0013. - */ - Root: FunctionComponent - call: CallFunction - upsert: UpsertFunction - end: ((promise: Promise, response: Response) => void) & - ((response: Response) => void) - update: ((promise: Promise, props: Partial) => void) & - ((props: Partial) => void) - } +export type Callable< + Props, + Response, + MutationPayload = void, + RootProps = {}, +> = FunctionComponent & { + /** + * @deprecated Use `` directly — `Confirm.Root === Confirm`. + * Kept as an alias for backwards compatibility; no removal date. + * See ADR-0013. + */ + Root: FunctionComponent + call: CallFunction + upsert: UpsertFunction + end: ((promise: Promise, response: Response) => void) & + ((response: Response) => void) + update: ((promise: Promise, props: Partial) => void) & + ((props: Partial) => void) +} diff --git a/packages/react-call/src/types.public.ts b/packages/react-call/src/types.public.ts index 80328db..995d442 100644 --- a/packages/react-call/src/types.public.ts +++ b/packages/react-call/src/types.public.ts @@ -2,6 +2,9 @@ export type { CallFunction as Function, UpsertFunction, CallContext as Context, + CallOptions, + MutationContext, + MutationFunction, PropsWithCall as Props, UserComponent, Callable,