@retronew/call-vue 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 retronew
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # @retronew/call-vue
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40retronew%2Fcall-vue.svg)](https://www.npmjs.com/package/@retronew/call-vue)
4
+ [![npm downloads](https://img.shields.io/npm/dm/%40retronew%2Fcall-vue.svg)](https://www.npmjs.com/package/@retronew/call-vue)
5
+ [![minzipped size](https://img.shields.io/bundlephobia/minzip/%40retronew%2Fcall-vue)](https://bundlephobia.com/package/@retronew/call-vue)
6
+ [![License: MIT](https://img.shields.io/npm/l/%40retronew%2Fcall-vue.svg)](../../LICENSE)
7
+
8
+ Call & await Vue components like async functions. A Vue 3 port of
9
+ [`react-call`](https://github.com/desko27/react-call)'s core API
10
+ (`createCallable`, `call`/`upsert`/`end`/`update`) built on native Vue
11
+ reactivity — no context providers, no global store to wire up.
12
+
13
+ ## Why
14
+
15
+ Confirmation dialogs, prompts, and toasts are usually one-off components you
16
+ have to mount, manage `v-model`/visibility for, and thread a resolve callback
17
+ through. `createCallable` turns the component itself into an awaitable
18
+ function:
19
+
20
+ ```ts
21
+ const confirmed = await Confirm.call({ message: 'Delete this file?' })
22
+ if (confirmed) deleteFile()
23
+ ```
24
+
25
+ No global state, no extra store — `<Confirm />` mounted once *is* the stack.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pnpm add @retronew/call-vue
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ 1. Define the component. It receives your own props **plus** an injected
36
+ `call` prop carrying the imperative context for this instance:
37
+
38
+ ```vue
39
+ <!-- Confirm.vue -->
40
+ <script setup lang="ts">
41
+ import type { PropsWithCall } from '@retronew/call-vue'
42
+
43
+ type Props = { message: string }
44
+ type Response = boolean
45
+
46
+ defineProps<PropsWithCall<Props, Response, Record<string, never>>>()
47
+ </script>
48
+
49
+ <template>
50
+ <div class="dialog">
51
+ <p>{{ message }}</p>
52
+ <button @click="call.end(true)">Yes</button>
53
+ <button @click="call.end(false)">No</button>
54
+ </div>
55
+ </template>
56
+ ```
57
+
58
+ 2. Wrap it with `createCallable` and mount the result once, anywhere near the
59
+ root of your app:
60
+
61
+ ```ts
62
+ // confirm.ts
63
+ import { createCallable } from '@retronew/call-vue'
64
+ import ConfirmDialog from './Confirm.vue'
65
+
66
+ export const Confirm = createCallable<{ message: string }, boolean>(ConfirmDialog)
67
+ ```
68
+
69
+ ```vue
70
+ <!-- App.vue -->
71
+ <script setup lang="ts">
72
+ import { Confirm } from './confirm'
73
+ </script>
74
+
75
+ <template>
76
+ <Confirm />
77
+ <!-- rest of the app -->
78
+ </template>
79
+ ```
80
+
81
+ 3. Call it like an async function, from anywhere:
82
+
83
+ ```ts
84
+ import { Confirm } from './confirm'
85
+
86
+ async function handleDelete() {
87
+ const confirmed = await Confirm.call({ message: 'Delete this file?' })
88
+ if (confirmed) await deleteFile()
89
+ }
90
+ ```
91
+
92
+ ## API
93
+
94
+ - **`Confirm.call(props)`** → `Promise<Response>` — pushes a new instance onto
95
+ the stack; resolves when that instance's `call.end(response)` runs.
96
+ - **`Confirm.upsert(props)`** → `Promise<Response>` — like `call`, but reuses
97
+ the in-flight instance instead of stacking a new one while one is still
98
+ open. Ideal for a single "status" toast whose props change over time
99
+ (`Confirm.upsert({ text: 'Uploading… 42%' })`).
100
+ - **`Confirm.end(response)`** / **`Confirm.end(promise, response)`** —
101
+ resolve every open call, or just the one identified by the promise `call()`/
102
+ `upsert()` returned.
103
+ - **`Confirm.update(props)`** / **`Confirm.update(promise, props)`** —
104
+ shallow-merge partial props into every open call, or just one, without
105
+ resolving it.
106
+ - **`call.key`**, **`call.ended`**, **`call.index`**, **`call.stackSize`**,
107
+ **`call.root`** — read inside the component: stable identity, whether
108
+ `end()` already ran (so you can key an exit transition off it), this
109
+ instance's position in the stack, how many are open, and whatever props
110
+ were passed to `<Confirm rootProp="…" />`.
111
+
112
+ See the [Claude Code skill](skills/call-vue/SKILL.md) for the stacking model,
113
+ `unmountingDelay` exit-transition pattern, and the single-`<Root>` constraint
114
+ in depth.
115
+
116
+ ## Exit transitions
117
+
118
+ Pass a second argument to `createCallable` to keep an ended call mounted
119
+ for N milliseconds — long enough for a `<Transition>` to play — before it's
120
+ actually removed from the stack:
121
+
122
+ ```ts
123
+ export const Toast = createCallable<Props, Response>(ToastCard, 200 /* ms */)
124
+ ```
125
+
126
+ Inside `ToastCard`, branch on `call.ended` to trigger the leave state.
127
+
128
+ ## Stacking
129
+
130
+ Every `call()` while a previous one is still open stacks on top of it —
131
+ `<Confirm />` renders one component instance per open call, each with its own
132
+ `call.index`/`call.stackSize`. Nothing is queued or hidden automatically;
133
+ that's a decision your component makes (e.g. only rendering the top of the
134
+ stack, or rendering all of them with a depth-based transform).
135
+
136
+ ## Constraints
137
+
138
+ - Exactly **one** `<Confirm />` may be mounted at a time. `call()`/`upsert()`
139
+ throw `No <Root> found!` if none is mounted yet, or
140
+ `Multiple instances of <Root> found!` if more than one is.
141
+ - Unmounting `<Confirm />` resets its stack — a fresh mount always starts
142
+ empty.
143
+
144
+ ## Claude Code skill
145
+
146
+ This package ships a [Claude Code skill](skills/call-vue/SKILL.md) covering
147
+ the stack/upsert model, exit-transition timing, and the single-`<Root>`
148
+ constraint. Claude Code doesn't auto-discover skills inside `node_modules`
149
+ yet, so after installing, copy or symlink it into your project:
150
+
151
+ ```bash
152
+ mkdir -p .claude/skills
153
+ cp -r node_modules/@retronew/call-vue/skills/call-vue .claude/skills/call-vue
154
+ ```
155
+
156
+ ## Credits
157
+
158
+ API design ported from [`react-call`](https://github.com/desko27/react-call)
159
+ by [@desko27](https://github.com/desko27), reimplemented from scratch on Vue
160
+ 3 reactivity primitives (`shallowRef`, no `useSyncExternalStore` equivalent
161
+ needed).
@@ -0,0 +1,72 @@
1
+ import { Component, DefineComponent } from "vue";
2
+ //#region src/createCallable/types.d.ts
3
+ /**
4
+ * Properties every `call.*` context carries, regardless of stack position.
5
+ * Mirrors `CallItemPublicProperties` in react-call's store.
6
+ */
7
+ interface CallItemPublicProperties<Response> {
8
+ /** Stable identity for this call within the stack; safe to use as a `:key`. */
9
+ key: string;
10
+ /** Resolve this specific call's promise and mark it `ended`. */
11
+ end: (response: Response) => void;
12
+ /**
13
+ * `true` once `end()` has resolved this call. The call stays mounted for
14
+ * `unmountingDelay` ms after this flips, so an exit transition can play.
15
+ */
16
+ ended: boolean;
17
+ }
18
+ /** The `call()` method returned by `createCallable`. */
19
+ type CallFunction<Props, Response> = (props: Props) => Promise<Response>;
20
+ /** The `upsert()` method returned by `createCallable`. */
21
+ type UpsertFunction<Props, Response> = (props: Props) => Promise<Response>;
22
+ /**
23
+ * The special `call` prop every user component receives, on top of its own
24
+ * `Props`. Carries per-call identity/state plus stack/root context.
25
+ */
26
+ type CallContext<_Props, Response, RootProps> = CallItemPublicProperties<Response> & {
27
+ /** Props passed to `<Root>` (the mounted callable component), if any. */
28
+ root: RootProps;
29
+ /** This call's position in the current stack (0 = oldest). */
30
+ index: number;
31
+ /** Number of calls currently stacked (including this one). */
32
+ stackSize: number;
33
+ };
34
+ /** User props merged with the injected `call` context. */
35
+ type PropsWithCall<Props, Response, RootProps> = Props & {
36
+ call: CallContext<Props, Response, RootProps>;
37
+ };
38
+ /**
39
+ * The shape `createCallable` expects: any Vue component (SFC, `defineComponent`,
40
+ * or functional) whose props satisfy `PropsWithCall<Props, Response, RootProps>`.
41
+ */
42
+ type UserComponent<Props, Response, RootProps> = Component<PropsWithCall<Props, Response, RootProps>>;
43
+ /**
44
+ * What `createCallable` returns.
45
+ *
46
+ * The callable is the Root component itself — mount it with `<Confirm />`
47
+ * (or `<Confirm.Root />`) and use the imperative methods (`call`, `upsert`,
48
+ * `end`, `update`) as properties on the very same object.
49
+ */
50
+ type Callable<Props, Response, RootProps> = DefineComponent<RootProps> & {
51
+ /** Alias for the callable itself — `Confirm.Root === Confirm`. */
52
+ Root: DefineComponent<RootProps>;
53
+ call: CallFunction<Props, Response>;
54
+ upsert: UpsertFunction<Props, Response>;
55
+ end: ((promise: Promise<Response>, response: Response) => void) & ((response: Response) => void);
56
+ update: ((promise: Promise<Response>, props: Partial<Props>) => void) & ((props: Partial<Props>) => void);
57
+ };
58
+ //#endregion
59
+ //#region src/createCallable/index.d.ts
60
+ /**
61
+ * Turns a Vue component into a "callable": mount it once as `<Confirm />`
62
+ * and then `await Confirm.call(props)` from anywhere to push an instance
63
+ * onto its stack and get back a promise that resolves when the instance
64
+ * calls `call.end(response)`.
65
+ *
66
+ * This is a Vue-native port of react-call's `createCallable` — see
67
+ * `skills/call-vue/SKILL.md` for the full mental model (stack, upsert,
68
+ * mutation flow).
69
+ */
70
+ declare function createCallable<Props = void, Response = void, RootProps extends Record<string, unknown> = Record<string, never>>(UserComponent: UserComponent<Props, Response, RootProps>, unmountingDelay?: number): Callable<Props, Response, RootProps>;
71
+ //#endregion
72
+ export { type CallContext, type CallFunction, type Callable, type PropsWithCall, type UpsertFunction, type UserComponent, createCallable };
package/dist/index.mjs ADDED
@@ -0,0 +1,170 @@
1
+ import { defineComponent, h, onUnmounted, shallowRef } from "vue";
2
+ //#region src/createCallable/store.ts
3
+ /**
4
+ * Vue-native replacement for react-call's `useSyncExternalStore`-backed
5
+ * store: `shallowRef` already gives us a single reactive snapshot per
6
+ * store, so there's no separate subscribe/getSnapshot machinery to hand-roll.
7
+ *
8
+ * `rootCount` plays the role react-call's `listeners.size` plays — it's how
9
+ * `assertSingleRoot` in `createCallable` detects a missing or duplicated
10
+ * `<Root>`. It's tracked here (rather than as its own `ref`) because it must
11
+ * never trigger a re-render on its own; only stack changes should.
12
+ */
13
+ function createStackStore() {
14
+ let nextKey = 0;
15
+ let upsertPromise = null;
16
+ let rootCount = 0;
17
+ const stack = shallowRef([]);
18
+ return {
19
+ stack,
20
+ add: (call) => {
21
+ stack.value = [...stack.value, {
22
+ ...call,
23
+ key: String(nextKey++)
24
+ }];
25
+ },
26
+ set: (promise, updateFn) => {
27
+ stack.value = stack.value.map((call) => promise && call.promise !== promise ? call : updateFn(call));
28
+ },
29
+ remove: (promises) => {
30
+ stack.value = stack.value.filter((c) => !promises.has(c.promise));
31
+ },
32
+ /** Call once from a mounted `<Root>`; call the returned function on unmount. */
33
+ mountRoot: () => {
34
+ rootCount++;
35
+ return () => {
36
+ rootCount--;
37
+ if (rootCount === 0) {
38
+ nextKey = 0;
39
+ stack.value = [];
40
+ upsertPromise = null;
41
+ }
42
+ };
43
+ },
44
+ getRootCount: () => rootCount,
45
+ getUpsertPromise: () => upsertPromise,
46
+ setUpsertPromise: (p) => {
47
+ upsertPromise = p;
48
+ }
49
+ };
50
+ }
51
+ //#endregion
52
+ //#region src/createCallable/index.ts
53
+ /**
54
+ * Turns a Vue component into a "callable": mount it once as `<Confirm />`
55
+ * and then `await Confirm.call(props)` from anywhere to push an instance
56
+ * onto its stack and get back a promise that resolves when the instance
57
+ * calls `call.end(response)`.
58
+ *
59
+ * This is a Vue-native port of react-call's `createCallable` — see
60
+ * `skills/call-vue/SKILL.md` for the full mental model (stack, upsert,
61
+ * mutation flow).
62
+ */
63
+ function createCallable(UserComponent, unmountingDelay = 0) {
64
+ const store = createStackStore();
65
+ const createEnd = (promise) => (response) => {
66
+ const ending = /* @__PURE__ */ new Set();
67
+ store.set(promise, (call) => {
68
+ call.resolve(response);
69
+ ending.add(call.promise);
70
+ return {
71
+ ...call,
72
+ ended: true
73
+ };
74
+ });
75
+ globalThis.setTimeout(() => store.remove(ending), unmountingDelay);
76
+ };
77
+ const assertSingleRoot = () => {
78
+ const count = store.getRootCount();
79
+ if (!count) throw new Error("No <Root> found!");
80
+ if (count > 1) throw new Error("Multiple instances of <Root> found!");
81
+ };
82
+ const call = (props) => {
83
+ assertSingleRoot();
84
+ let resolve;
85
+ const promise = new Promise((res) => {
86
+ resolve = res;
87
+ });
88
+ store.add({
89
+ props,
90
+ end: createEnd(promise),
91
+ ended: false,
92
+ promise,
93
+ resolve
94
+ });
95
+ return promise;
96
+ };
97
+ const upsert = (props) => {
98
+ assertSingleRoot();
99
+ const existing = store.getUpsertPromise();
100
+ if (existing) {
101
+ store.set(existing, (c) => ({
102
+ ...c,
103
+ props
104
+ }));
105
+ return existing;
106
+ }
107
+ let resolve;
108
+ const promise = new Promise((res) => {
109
+ resolve = res;
110
+ });
111
+ store.setUpsertPromise(promise);
112
+ store.add({
113
+ props,
114
+ end: (response) => {
115
+ store.setUpsertPromise(null);
116
+ createEnd(promise)(response);
117
+ },
118
+ ended: false,
119
+ promise,
120
+ resolve
121
+ });
122
+ return promise;
123
+ };
124
+ const end = (...args) => {
125
+ const targeted = args.length === 2;
126
+ const promise = targeted ? args[0] : null;
127
+ const response = targeted ? args[1] : args[0];
128
+ if (!targeted || promise === store.getUpsertPromise()) store.setUpsertPromise(null);
129
+ return createEnd(promise)(response);
130
+ };
131
+ const update = (...args) => {
132
+ const targeted = args.length === 2;
133
+ store.set(targeted ? args[0] : null, (c) => ({
134
+ ...c,
135
+ props: {
136
+ ...c.props,
137
+ ...targeted ? args[1] : args[0]
138
+ }
139
+ }));
140
+ };
141
+ const Root = defineComponent({
142
+ name: "CallableRoot",
143
+ inheritAttrs: false,
144
+ setup(_, { attrs }) {
145
+ const unmountRoot = store.mountRoot();
146
+ onUnmounted(unmountRoot);
147
+ return () => store.stack.value.map((item, index, stack) => h(UserComponent, {
148
+ ...item.props,
149
+ key: item.key,
150
+ call: {
151
+ key: item.key,
152
+ end: item.end,
153
+ ended: item.ended,
154
+ root: attrs,
155
+ index,
156
+ stackSize: stack.length
157
+ }
158
+ }));
159
+ }
160
+ });
161
+ return Object.assign(Root, {
162
+ Root,
163
+ call,
164
+ upsert,
165
+ end,
166
+ update
167
+ });
168
+ }
169
+ //#endregion
170
+ export { createCallable };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@retronew/call-vue",
3
+ "version": "0.2.0",
4
+ "description": "Call & await Vue components like async functions — a Vue 3 port of react-call.",
5
+ "keywords": [
6
+ "async",
7
+ "composable",
8
+ "confirm",
9
+ "dialog",
10
+ "modal",
11
+ "vue",
12
+ "vue3"
13
+ ],
14
+ "homepage": "https://github.com/retronew/ui-kit#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/retronew/ui-kit/issues"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/retronew/ui-kit.git",
22
+ "directory": "packages/call-vue"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "skills"
27
+ ],
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.mts",
33
+ "default": "./dist/index.mjs"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.2.0",
42
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
43
+ "@vue/test-utils": "^2.4.11",
44
+ "jsdom": "^30.0.1",
45
+ "typescript": "^7.0.2",
46
+ "vite-plus": "^0.2.9",
47
+ "vitest": "4.1.10",
48
+ "vue": "^3.5.41"
49
+ },
50
+ "peerDependencies": {
51
+ "vue": "^3.5.0"
52
+ },
53
+ "scripts": {
54
+ "build": "vp pack",
55
+ "dev": "vp pack --watch",
56
+ "test": "vp test",
57
+ "test:coverage": "vp test --coverage",
58
+ "check": "vp check"
59
+ }
60
+ }
@@ -0,0 +1,164 @@
1
+ ---
2
+ name: call-vue
3
+ description: Reach for @retronew/call-vue (createCallable) when building UI that resolves a value back to its caller — confirmations, dialogs, form modals, toasts, notifications, context menus, pickers. Use when a Vue task involves any such "await the UI" interaction, when code imports createCallable from @retronew/call-vue, or when the user mentions call-vue, react-call, or Callables in a Vue project. If call-vue isn't a dependency yet but the problem fits, propose adding it. Covers Declare→Root→Call, call vs upsert, exit transitions, and the single-Root rule.
4
+ ---
5
+
6
+ # @retronew/call-vue
7
+
8
+ `createCallable()` turns a Vue component into something you can `await`: you
9
+ call it imperatively from anywhere and it resolves with a value. This is a
10
+ Vue-native port of [`react-call`](https://github.com/desko27/react-call)'s
11
+ core API — same mental model, built on Vue's own reactivity (`shallowRef`)
12
+ instead of React's `useSyncExternalStore`.
13
+
14
+ ## When to reach for it (and when not)
15
+
16
+ **Reach for it** when a piece of UI conceptually *returns a value to its
17
+ caller* and you want to `await` that value from async code: confirmations,
18
+ dialogs, form modals, toasts/notifications, context menus, pickers,
19
+ multi-step wizards.
20
+
21
+ **Propose it** if `@retronew/call-vue` isn't a dependency yet but the task
22
+ fits — then `pnpm add @retronew/call-vue`.
23
+
24
+ **Don't push it** when another solution is already in place and working —
25
+ mention it as an option, don't refactor unprompted. Skip it for purely
26
+ presentational components that return nothing, and for full-page flows better
27
+ served by routing.
28
+
29
+ ## Vocabulary (use these exact terms)
30
+
31
+ - **Callable** — the value `createCallable()` returns. It is *both* a Vue
32
+ component (mount `<Confirm />`) *and* a namespace of methods (`call`,
33
+ `upsert`, `end`, `update`) attached to the same object. Don't call it a
34
+ "modal/dialog/component".
35
+ - **Root** — the mounting form of the Callable: the bare `<Confirm />`
36
+ (`Confirm.Root === Confirm`, kept as an alias). Not a "provider/portal".
37
+ - **Call** — one imperative invocation (`Confirm.call({...})`), resolves to a
38
+ **Response**.
39
+ - **Stack** — the ordered list of active Calls a Root renders (not a
40
+ "queue"). Each renders as one instance of the user component.
41
+ - **CallContext** — the `call` prop your component receives: `{ end, ended,
42
+ key, index, stackSize, root }`. Not a Vue "provide/inject context".
43
+ - **Upsert** — singleton-style Call (`upsert()`).
44
+
45
+ ## The model: Declare → Root → Call
46
+
47
+ ```vue
48
+ <!-- Confirm.vue -->
49
+ <script setup lang="ts">
50
+ import type { PropsWithCall } from '@retronew/call-vue'
51
+
52
+ type Props = { message: string }
53
+ type Response = boolean
54
+
55
+ // `call` is the special injected prop (the CallContext)
56
+ defineProps<PropsWithCall<Props, Response, Record<string, never>>>()
57
+ </script>
58
+
59
+ <template>
60
+ <div role="dialog">
61
+ <p>{{ message }}</p>
62
+ <button @click="call.end(true)">Yes</button>
63
+ <button @click="call.end(false)">No</button>
64
+ </div>
65
+ </template>
66
+ ```
67
+
68
+ ```ts
69
+ // confirm.ts — 1. Declare
70
+ import { createCallable } from '@retronew/call-vue'
71
+ import ConfirmDialog from './Confirm.vue'
72
+
73
+ export const Confirm = createCallable<{ message: string }, boolean>(ConfirmDialog)
74
+ ```
75
+
76
+ ```vue
77
+ <!-- App.vue — 2. Root: mount once, somewhere always rendered -->
78
+ <template>
79
+ <Confirm />
80
+ </template>
81
+ ```
82
+
83
+ ```ts
84
+ // 3. Call & await — from anywhere
85
+ const accepted = await Confirm.call({ message: 'Continue?' })
86
+ ```
87
+
88
+ Generics are `createCallable<Props, Response, RootProps>` (all optional,
89
+ default to `void`/`void`/`Record<string, never>`).
90
+
91
+ Plain functional components work too — `createCallable` accepts anything
92
+ matching Vue's `Component<Props>` type, including a bare
93
+ `(props) => VNode` function, which is often less ceremony than an SFC for a
94
+ tiny callable.
95
+
96
+ ## Decision guide
97
+
98
+ - **`call` vs `upsert`** — `call()` opens a new Call every time (they stack).
99
+ `upsert()` is singleton: the first invocation creates the Call, later
100
+ `upsert()` calls update the same one and return the same promise. Use
101
+ `upsert` for toasts, progress, loading — anything that should have at most
102
+ one instance alive.
103
+ - **Root props vs call props** — per-Call data goes in `call()`'s props; data
104
+ shared across every Call (theme, current user) goes in **RootProps**,
105
+ passed as ordinary attrs to `<Confirm userName="…" />` and read back via
106
+ `call.root` inside every mounted instance. `RootProps` arrives through
107
+ Vue's `$attrs` under the hood — `<Root>` declares no props of its own, so
108
+ it forwards whatever you hand it as-is.
109
+ - **End / update from the caller** — `Confirm.end(promise, value)` /
110
+ `Confirm.update(promise, partialProps)` target one Call; omit the promise
111
+ argument to affect every currently active Call instead.
112
+
113
+ ## Hard rules (the common failures)
114
+
115
+ - **One Root per Callable.** Mounting `<Confirm />` in two live places throws
116
+ *"Multiple instances of `<Root>` found!"* the next time `call()`/`upsert()`
117
+ runs. Don't mount a Callable per-route or per-feature — mount it once, high
118
+ in the tree (e.g. `App.vue`), same as you'd mount a single toast outlet.
119
+ - **Mount the Root where it stays alive when you call.** If the Root sits in
120
+ a `v-if`-gated or route-unmounted subtree, `call()` from outside it throws
121
+ *"No `<Root>` found!"*. If that's happening, check whether the Root got
122
+ unmounted before the call, not whether `createCallable` was set up wrong.
123
+ - **Unmounting resets the stack.** A Callable's stack is scoped to its
124
+ currently-mounted Root instance — remount it (e.g. via `v-if` toggling
125
+ off/on, or HMR) and any Calls made against the previous mount are gone;
126
+ there's no persistence across mounts.
127
+ - **Exit transitions** need the unmount delay as the 2nd argument to
128
+ `createCallable`, then drive the leave state off `call.ended` (a boolean
129
+ prop, not a Vue `<Transition>` hook by itself — combine the two):
130
+
131
+ ```ts
132
+ export const Toast = createCallable<Props, Response>(ToastCard, 200 /* ms */)
133
+ ```
134
+
135
+ Inside `ToastCard`, branch on `props.call.ended` to add a leaving class or
136
+ swap into an exit-animation state; the instance stays mounted for 200ms
137
+ after `end()` so a CSS transition (or Vue `<Transition>` wrapping the
138
+ Callable's stack render) has time to play before removal.
139
+
140
+ ## Anti-patterns
141
+
142
+ - Placing `<Confirm />` per-route or per-feature → multi-Root throw. One
143
+ mount, reused everywhere via `.call()`.
144
+ - Reusing `call()` for singleton UI (toasts, a single progress indicator) →
145
+ duplicate stacked instances. Use `upsert()` instead.
146
+ - Treating the Callable as a plain component to render with data props — the
147
+ props you pass to `<Confirm />` itself are **RootProps** (shared context),
148
+ never the per-instance data; that always goes through `.call(props)`.
149
+ - Reaching into `call.root` for data that changes per-Call — it's constant
150
+ for the Root's whole lifetime (whatever `<Confirm />` was mounted with);
151
+ put per-Call data in the component's own props instead.
152
+
153
+ ## Quick reference
154
+
155
+ | Method | Targeted form | Untargeted form |
156
+ |---|---|---|
157
+ | `call(props)` | — | opens a new Call, returns its `Promise<Response>` |
158
+ | `upsert(props)` | — | opens or updates the pending upsert Call |
159
+ | `end(response)` / `end(promise, response)` | resolves the one Call | resolves every open Call |
160
+ | `update(props)` / `update(promise, props)` | merges into the one Call | merges into every open Call |
161
+
162
+ `call` prop shape inside the component: `{ key, end, ended, root, index,
163
+ stackSize }` — see the package [README](../../README.md) for the full field
164
+ descriptions.