From 6f6f0591fec19c737557757a74752b20f17888e4 Mon Sep 17 00:00:00 2001 From: desko27 Date: Sun, 31 May 2026 20:01:01 +0200 Subject: [PATCH 1/7] Add publishable react-call usage skill for LLMs Ship a consumer-usage agent skill at skills/react-call/ (SKILL.md + references), installable with `npx skills add desko27/react-call --skill react-call`. Broad reach-for trigger, decision/gotcha-oriented, uses the CONTEXT.md glossary, covers v2.x. Add a CI drift guard (packages/react-call/src/__tests__/skill-symbols.test.ts) that fails if the skill imports a react-call export/subpath that no longer exists, and a README "AI agent skill" section with the install command. See docs/adr/0021-publish-consumer-usage-skill.md. --- README.md | 11 ++ docs/adr/0021-publish-consumer-usage-skill.md | 23 +++ .../src/__tests__/skill-symbols.test.ts | 143 ++++++++++++++++++ skills/react-call/SKILL.md | 115 ++++++++++++++ skills/react-call/references/host.md | 68 +++++++++ skills/react-call/references/mutation-flow.md | 94 ++++++++++++ skills/react-call/references/ssr-and-lazy.md | 58 +++++++ skills/react-call/references/types.md | 69 +++++++++ 8 files changed, 581 insertions(+) create mode 100644 docs/adr/0021-publish-consumer-usage-skill.md create mode 100644 packages/react-call/src/__tests__/skill-symbols.test.ts create mode 100644 skills/react-call/SKILL.md create mode 100644 skills/react-call/references/host.md create mode 100644 skills/react-call/references/mutation-flow.md create mode 100644 skills/react-call/references/ssr-and-lazy.md create mode 100644 skills/react-call/references/types.md diff --git a/README.md b/README.md index 37af889..f2a5dfb 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ menus, pickers — any UI that conceptually returns a value to its caller. - [Lazy loading](#lazy-loading) - [SSR](#ssr) - [Next.js / RSC](#nextjs--rsc) +- [AI agent skill](#ai-agent-skill) - [Migrating from v1](#migrating-from-v1) # Getting started @@ -507,6 +508,16 @@ export const Confirm = createCallable(...) Then `` mounts cleanly from any Server Component (e.g. `app/layout.tsx`). +# AI agent skill + +Using an AI coding assistant (Claude Code, Cursor, …)? Install the official react-call skill so it writes correct Callables — the Declare→Root→Call model, `call` vs `upsert`, mutation flow, multi-preview hosts, SSR, the single-Root rule, and the canonical vocabulary: + +```sh +npx skills add desko27/react-call --skill react-call +``` + +The `--skill react-call` flag pins exactly this skill (this repo also hosts the maintainers' internal workflow skills, which you don't want). Powered by [`skills`](https://github.com/vercel-labs/skills) — works with any agent it supports. + # Migrating from v1 If you're upgrading from 1.x, see the [full v2 changelog](packages/react-call/CHANGELOG.md). The breaking changes in short: diff --git a/docs/adr/0021-publish-consumer-usage-skill.md b/docs/adr/0021-publish-consumer-usage-skill.md new file mode 100644 index 0000000..1b87111 --- /dev/null +++ b/docs/adr/0021-publish-consumer-usage-skill.md @@ -0,0 +1,23 @@ +# Ship a consumer-usage agent skill, published from `skills/react-call/` + +To help LLM coding agents write correct react-call code, the repo ships an installable agent skill — a `SKILL.md` consumers add to their own project with `npx skills add desko27/react-call --skill react-call` (the `vercel-labs/skills` CLI, the same tool this repo already uses to vendor its workflow skills). It is a single, **consumer-usage** skill: it teaches an agent to author Callables (the Declare→Root→Call model, `call` vs `upsert`, mutation flow, Hosts, SSR, the single-Root rule), not how to work on the library's internals. It lives in a top-level `skills/` directory — kept deliberately separate from the consumed/vendored skills in `.agents/skills/` — and is distributed from GitHub, not the npm tarball. + +The skill triggers **broadly**: it fires on the class of problem react-call solves (UI that resolves a value back to its caller — confirmations, dialogs, form modals, toasts, pickers), and if react-call is not yet a dependency but the problem fits, it proposes adding it. The rationale is that *installing the skill is itself the opt-in*: a developer only adds it to a project where they intend to use react-call for this kind of UI, so a conservative "only fire when react-call is already imported" trigger would withhold help exactly when it is most useful — at the moment new code is being written. Its conduct is low-friction: capture new cases, surface react-call as an option where another solution is already in place, but do not crusade to replace working incumbents. + +## Considered options + +- **A single broad-trigger skill named `react-call` (chosen).** One artifact, clean install command, no duplication to maintain. The broad trigger matches install-intent. +- **Two skills: scoped `react-call` + broad `reach-for-react-call`, composed from a shared DRY source.** Considered at length. The scoped variant would fire only when react-call is already present (safe for global `-g` installs and mixed codebases); the broad variant would proactively recommend it. Rejected for v1: install-is-intent makes the scoped variant redundant for the dominant per-project install, and maintaining two self-contained artifacts (the CLI copies a skill's own directory, so they cannot share files at install time) requires a release-time compose step — cost without payoff until a real global-install audience asks for it. Adding `react-call-scoped` later is cheap. +- **Place the skill in `.agents/skills/` next to the vendored workflow skills.** Rejected: `npx skills add desko27/react-call` walks `.agents/skills/` and `.claude/skills/` as discovery locations, so the authored skill would be listed alongside all ~15 vendored workflow skills (tdd, caveman, grill-with-docs…) — irrelevant and confusing to a library consumer. A top-level `skills/` directory cleanly separates *authored-for-publication* from *consumed*. +- **Document the bare `npx skills add desko27/react-call` (no `--skill`).** Rejected: discovery still surfaces the vendored skills from `.agents/skills/`/`.claude/skills/`, so the consumer gets a menu. Pinning with `--skill react-call` installs exactly one and makes the first impression clean. The `--skill` flag is therefore mandatory in the documented command. +- **Ship `SKILL.md` inside the npm package (`files`).** Rejected: `npx skills` resolves from GitHub, not npm; bundling it would bloat every `npm install react-call` for zero install-path benefit. +- **Publish from a separate dedicated repo (`desko27/react-call-skill`).** Rejected: a second repo to maintain, guaranteed drift from the library it documents, and not where people look. Co-locating with the code and README keeps one source of truth. + +## Consequences + +- **The README documents one canonical install command: `npx skills add desko27/react-call --skill react-call`.** Dropping `--skill` is a documented foot-gun (lists the vendored skills), so the flag is never omitted in docs. +- **Discovery space is shared.** `.agents/skills/` and `.claude/skills/` remain discovery locations the CLI walks; `skills/react-call/` simply adds one more. Nothing prevents a user from `--list`-ing and seeing everything — the `--skill` pin is the contract, not an enforcement. +- **Content is decision- and pitfall-oriented, not a README clone.** `SKILL.md` carries the mental model, a `call`/`upsert`/mutation-flow decision guide, the hard rules (single Root, client-only `call()`, where to mount), one canonical example, and a "when to reach for react-call (and when not)" gate. Depth lives in `references/` (`mutation-flow.md`, `host.md`, `ssr-and-lazy.md`, `types.md`). It is self-contained (no fetch at task time) with pointers to `react-call.desko.dev` for further reading. This avoids a second copy of the ~530-line README drifting from the original. +- **The skill consumes `CONTEXT.md`'s vocabulary; it adds none.** Library machinery is named with canonical terms (Callable, Root, Call, Upsert, MutationFlow, MutationFn, Host…) and respects the glossary's _Avoid_ lists; use cases stay in plain language (a "toast" is a use case, the operation is **Upsert**). No new domain terms, so `CONTEXT.md` is unchanged. +- **Versioning is git/hash-based, not changesets.** `npx skills` pins the skill by commit/hash in the consumer's lockfile; the skill is not part of the npm release. It declares "Covers react-call v2.x" and instructs the agent to defer to the installed version if it differs. +- **A CI symbol-existence guard keeps the skill honest.** A lightweight check in this repo fails the build if the skill references a public export that no longer exists (`createCallable`, `useMutationFlow`, the public types, the `react-call/*` subpaths) against `main.ts` and the `.public` types. Operating rule: an API-breaking change to the library updates the skill in the same PR. diff --git a/packages/react-call/src/__tests__/skill-symbols.test.ts b/packages/react-call/src/__tests__/skill-symbols.test.ts new file mode 100644 index 0000000..9167356 --- /dev/null +++ b/packages/react-call/src/__tests__/skill-symbols.test.ts @@ -0,0 +1,143 @@ +import { readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Drift guard for the published consumer skill (see ADR-0021). The skill at +// `skills/react-call/` teaches an LLM the public API; if a `react-call` export +// or subpath it imports is renamed/removed, this test fails so the skill is +// updated in the same PR rather than silently lying to consumers. + +const REPO_ROOT = join(import.meta.dirname, '..', '..', '..', '..') +const PKG = join(REPO_ROOT, 'packages', 'react-call') +const SKILL_DIR = join(REPO_ROOT, 'skills', 'react-call') + +const SKILL_FILES = [ + join(SKILL_DIR, 'SKILL.md'), + join(SKILL_DIR, 'references', 'mutation-flow.md'), + join(SKILL_DIR, 'references', 'host.md'), + join(SKILL_DIR, 'references', 'ssr-and-lazy.md'), + join(SKILL_DIR, 'references', 'types.md'), +] + +// Subpath specifier -> source entry whose exports back it. +const ENTRY_SOURCES: Record = { + 'react-call': join(PKG, 'src', 'main.ts'), + 'react-call/mutation-flow': join(PKG, 'src', 'mutation-flow', 'index.ts'), + 'react-call/host': join(PKG, 'src', 'host', 'index.tsx'), + 'react-call/vite': join(PKG, 'src', 'vite', 'index.ts'), +} + +const rel = (file: string) => relative(REPO_ROOT, file) + +/** Names a source file exports (incl. type-only and re-exports), plus default. */ +function exportedNames(src: string): { + names: Set + hasDefault: boolean +} { + const names = new Set() + // `export { a, b as c } [from '…']` and `export type { … }` + for (const m of src.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) { + for (const raw of m[1].split(',')) { + const name = raw + .trim() + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] + .trim() + if (name) names.add(name) + } + } + // `export const|function|class|interface|type NAME` + const decl = + /export\s+(?:async\s+)?(?:const|function|class|interface|type)\s+([A-Za-z0-9_$]+)/g + for (const m of src.matchAll(decl)) names.add(m[1]) + return { names, hasDefault: /export\s+default\b/.test(src) } +} + +/** Parse the binding clause of an `import … from` statement. */ +function parseClause(clause: string): { named: string[]; hasDefault: boolean } { + const named: string[] = [] + let rest = clause.trim() + const brace = rest.match(/\{([^}]*)\}/) + if (brace) { + for (const raw of brace[1].split(',')) { + const t = raw.trim() + if (!t) continue + const name = t + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] + .trim() + if (name) named.push(name) + } + rest = rest.replace(brace[0], '').replace(/,/g, ' ') + } + rest = rest.replace(/^type\b/, '').trim() + const def = rest.match(/^([A-Za-z0-9_$*]+)/) + const hasDefault = !!def && def[1] !== '*' + return { named, hasDefault } +} + +interface ImportRef { + file: string + spec: string + named: string[] + hasDefault: boolean +} + +function extractReactCallImports(file: string): ImportRef[] { + const text = readFileSync(file, 'utf8') + const refs: ImportRef[] = [] + const re = /import\s+([^;]*?)\s+from\s+['"](react-call(?:\/[^'"]+)?)['"]/g + for (const m of text.matchAll(re)) { + const { named, hasDefault } = parseClause(m[1]) + refs.push({ file, spec: m[2], named, hasDefault }) + } + return refs +} + +const pkg = JSON.parse(readFileSync(join(PKG, 'package.json'), 'utf8')) +const validSubpaths = new Set( + Object.keys(pkg.exports) + .filter((k) => k !== './package.json') + .map((k) => (k === '.' ? 'react-call' : k.replace(/^\.\//, 'react-call/'))), +) + +const refs = SKILL_FILES.flatMap(extractReactCallImports) +const sourceExports = new Map>() +for (const spec of Object.keys(ENTRY_SOURCES)) { + sourceExports.set( + spec, + exportedNames(readFileSync(ENTRY_SOURCES[spec], 'utf8')), + ) +} + +describe('react-call skill stays in sync with the public API (ADR-0021)', () => { + it('finds the react-call imports it is meant to guard', () => { + // Catches a broken path/glob silently making the suite vacuous. + expect(refs.length).toBeGreaterThan(0) + }) + + it('imports only declared package subpaths', () => { + for (const ref of refs) { + expect( + [...validSubpaths], + `${ref.spec} imported in ${rel(ref.file)} is not a package export`, + ).toContain(ref.spec) + } + }) + + it('imports only real public exports', () => { + for (const ref of refs) { + const exp = sourceExports.get(ref.spec) + if (!exp) continue // unknown subpath already failed above + for (const name of ref.named) { + expect( + [...exp.names], + `${name} from '${ref.spec}' (in ${rel(ref.file)}) is not exported`, + ).toContain(name) + } + if (ref.hasDefault) { + expect(exp.hasDefault, `'${ref.spec}' has no default export`).toBe(true) + } + } + }) +}) diff --git a/skills/react-call/SKILL.md b/skills/react-call/SKILL.md new file mode 100644 index 0000000..24372d4 --- /dev/null +++ b/skills/react-call/SKILL.md @@ -0,0 +1,115 @@ +--- +name: react-call +description: Reach for the react-call library (createCallable) when building UI that resolves a value back to its caller — confirmations, dialogs, form modals, toasts, notifications, context menus, pickers. Use when a React task involves any such "await the UI" interaction, when code imports createCallable, useMutationFlow, react-call/host, or react-call/vite, or when the user mentions react-call or Callables. If react-call isn't a dependency yet but the problem fits, propose adding it. Covers Declare→Root→Call, call vs upsert, mutation flow, multi-preview Hosts, SSR, and the single-Root rule. +--- + +# react-call + +`createCallable()` turns a React component into something you can `await`: you +call it imperatively from anywhere and it resolves with a value. This skill +covers **react-call v2.x** — check the consumer's `package.json` and defer to the +installed version if it differs. + +Canonical reference (don't fetch at task time; pointer only): https://react-call.desko.dev + +## When to reach for react-call (and when not) + +**Reach for it** when a piece of UI conceptually *returns a value to its caller* +and you want to `await` that value from async code: confirmations, dialogs, form +modals, toasts/notifications, context menus, pickers, multi-step wizards. + +**Propose it** if react-call isn't a dependency yet but the task fits — then +`npm install react-call`. + +**Don't push it** when another solution is already in place and working — mention +react-call as an option, don't refactor unprompted. Skip it for purely +presentational components that return nothing, and for full-page flows better +served by routing. + +## Vocabulary (use these exact terms) + +- **Callable** — the value `createCallable()` returns. It is *both* a React + component (mount ``) *and* a namespace of methods (`call`, `upsert`, + `end`, `update`). Don't call it a "modal/dialog/component". +- **Root** — the mounting form of the Callable: the bare ``. Not a + "provider/portal/outlet". +- **Call** — one imperative invocation (`Confirm.call({...})`), resolves to a + **Response**. +- **Stack** — the ordered list of active Calls a Root renders (not a "queue"). +- **CallContext** — the `call` prop your component receives: `{ end, ended, key, + index, stackSize, root }`. Not a React "Context". +- **Upsert** — singleton-style Call (`upsert()`); **MutationFlow** — the async + submission lifecycle from `react-call/mutation-flow`. + +## The model: Declare → Root → Call + +```tsx +import { createCallable } from 'react-call' + +interface Props { message: string } +type Response = boolean + +// 1. Declare — `call` is the special prop (the CallContext) +export const Confirm = createCallable(({ call, message }) => ( +
+

{message}

+ + +
+)) + +// 2. Root — mount the Callable once, somewhere always rendered (e.g. App.tsx) +// + +// 3. Call & await — from anywhere +const accepted = await Confirm.call({ message: 'Continue?' }) +``` + +Generics are `createCallable` (all optional). + +## Decision guide + +- **`call` vs `upsert`** — `call()` opens a new Call every time (they stack). + `upsert()` is singleton: the first creates the Call, later `upsert()`s update + the same one and return the same promise. Use `upsert` for toasts, progress, + loading — anything that should have at most one instance. +- **`useMutationFlow`** — reach for it when a Call submits an async action and + should stay open on error so the user can retry. It manages `pending` and only + closes on an explicit `call.end()`. See [references/mutation-flow.md](references/mutation-flow.md). +- **Root props vs call props** — per-Call data goes in `call()`'s props; + data shared across every Call (theme, current user) goes in **RootProps**, + passed to `` and read via `call.root`. +- **End / update from the caller** — `Confirm.end(promise, value)` / + `Confirm.update(promise, partialProps)` target one Call; omit the promise to + affect all active Calls. + +## Hard rules (the common failures) + +- **One Root per Callable.** Mounting `` in two live places throws + *"Multiple instances of found!"* at `call()` time. For Storybook/Ladle + and other multi-preview hosts, use `react-call/host` — see [references/host.md](references/host.md). +- **`call()` is client-only.** Running it during SSR throws *"No + found!"*. In Next.js/RSC, mark the `createCallable` file `'use client'`. See + [references/ssr-and-lazy.md](references/ssr-and-lazy.md). +- **Mount the Root where it's alive when you call.** If the Root sits in a + conditionally-unmounted subtree, `call()` from outside it throws *"No + found!"*. Mount it high (layout/app shell). +- **Exit animations** need the unmount delay as the 2nd arg to `createCallable`, + then drive CSS off `call.ended`: + `createCallable(Component, 500)` + `className={call.ended ? 'leaving' : ''}`. + +## Anti-patterns + +- Placing `` per-route or per-feature → multi-Root throw. One mount. +- Calling `Confirm.call(...)` in a Server Component or during render → throws. + Call from event handlers / effects on the client. +- Reusing `call()` for singletons (toasts) → duplicate instances. Use `upsert()`. +- Treating the Callable as a plain component to render with props — it's the + Root; props passed to `` are **RootProps**, not Call props. + +## References + +- [references/mutation-flow.md](references/mutation-flow.md) — `useMutationFlow`, optional `mutationFn` + `.orEnd`, Payload, Manual-close path. +- [references/host.md](references/host.md) — multi-preview Hosts (Storybook, Ladle, …), `wrapper`, options. +- [references/ssr-and-lazy.md](references/ssr-and-lazy.md) — SSR / Next.js / RSC, `React.lazy`. +- [references/types.md](references/types.md) — public types, generic shapes, v1→v2 migration. diff --git a/skills/react-call/references/host.md b/skills/react-call/references/host.md new file mode 100644 index 0000000..fc6f37e --- /dev/null +++ b/skills/react-call/references/host.md @@ -0,0 +1,68 @@ +# Multi-preview Hosts (Storybook, Ladle, Histoire, react-cosmos) + +A **Host** is an environment that renders multiple isolated React subtrees in +parallel for previewing. If each preview's decorator mounts ``, every +preview registers its own Root and `Confirm.call()` throws *"Multiple instances +of found!"* the moment any preview triggers a Call (the single-Root +invariant). + +`react-call/host` exposes `mount()` — it puts a **single** shared Root in a +body-level `
` *outside* the previews, via its own `createRoot`. Call it once +from the host's preview entry file; your story decorators don't render Callables +at all. + +```tsx +// .storybook/preview.tsx +import { mount } from 'react-call/host' +import { Confirm } from '../src/Confirm' + +mount() + +const preview = { /* normal Storybook config */ } +export default preview +``` + +The mount is idempotent under HMR — saving `preview.tsx` doesn't double-mount, and +an open Call survives the edit. Your app's own `` mount stays where it +is; this helper only handles the preview environment. If you previously rendered +`` inside a story decorator, drop it from the decorator. + +## Providers + +The Root renders in its own React tree, separate from every preview — it does +**not** inherit context from story decorators. Pass theme/locale/router via +`wrapper`. + +```tsx +mount(, { + wrapper: ({ children }) => {children}, +}) +``` + +A static `wrapper` captures props once. If providers depend on Storybook globals +(toolbar toggles, args), subscribe inside the wrapper: + +```tsx +import { useGlobals } from '@storybook/preview-api' + +function ReactiveTheme({ children }: { children: ReactNode }) { + const [{ theme = 'light' }] = useGlobals() + return {children} +} + +mount(, { wrapper: ReactiveTheme }) +``` + +External stores (Zustand, Jotai, Redux — anything on `useSyncExternalStore`) work +the same way: both trees subscribe to the same source of truth. + +## Options + +```tsx +mount(element, { + wrapper?: ComponentType<{ children: ReactNode }>, + container?: HTMLElement, // default:
in document.body +}) +``` + +Works wherever React DOM does. diff --git a/skills/react-call/references/mutation-flow.md b/skills/react-call/references/mutation-flow.md new file mode 100644 index 0000000..86fbe7d --- /dev/null +++ b/skills/react-call/references/mutation-flow.md @@ -0,0 +1,94 @@ +# Mutation flow + +`useMutationFlow` (from the subpath `react-call/mutation-flow`) wires a Call to an +async submission — the **MutationFlow**. It manages `pending` for you, and because +closing the Call requires an explicit `call.end()`, a handler that doesn't reach +`end` leaves the Call open: the user can retry without losing their place. + +## Terms + +- **MutationFn** — the async handler the caller provides as a prop: + `(call, payload) => Promise`. Owns the side effects and decides when to + close the Call. +- **MutationCall** — the narrow view of CallContext the MutationFn receives: + just `{ end }`. Keeping it minimal lets MutationFn skip the `RootProps` generic. +- **Trigger** — what `useMutationFlow` returns. Calling it runs the MutationFn; + read `trigger.pending` for the in-flight UI state. +- **Fallback response** — the value used to close the Call when the Trigger fires + but no MutationFn was provided, delivered at the callsite via `.orEnd(value)`. +- **Manual-close path** — neither a MutationFn nor a Fallback response closes the + Call; the consumer leaves it open on purpose (a "No" button, click-outside). + +## Required MutationFn + +```tsx +import { createCallable } from 'react-call' +import { useMutationFlow, type MutationFn } from 'react-call/mutation-flow' + +type Props = { mutationFn: MutationFn } + +export const Confirm = createCallable(({ call, mutationFn }) => { + const submit = useMutationFlow(call, mutationFn) + return ( +
+ + +
+ ) +}) + +await Confirm.call({ + mutationFn: async (call) => { + await api.delete(id) + call.end(true) // the handler decides when to close + }, +}) +``` + +If the MutationFn throws, the Trigger swallows it, `pending` clears, and the Call +**stays open** — the handler itself owns `call.end()`. + +## Optional MutationFn + `.orEnd` + +Type the prop as optional and chain `.orEnd(value)` at the callsite. The chain +fires only when no MutationFn was provided; with one, it's a no-op. + +```tsx +type Props = { mutationFn?: MutationFn } + +export const Confirm = createCallable(({ call, mutationFn }) => { + const submit = useMutationFlow(call, mutationFn) + // closes with `true` (Fallback response) if no mutationFn ↓ + return +}) +``` + +- Provide `.orEnd(value)` → that value is the **Fallback response**. +- Omit `.orEnd` and pass no MutationFn → the Call is on the **Manual-close path** + (open until something else closes it). Only reachable when the MutationFn prop + is typed as possibly-undefined. + +## Payload + +`MutationFn` — `Payload` is the second generic, defaults to +`void`, so `submit()` takes no argument unless you opt in. It's typed end-to-end +(the Trigger callsite and the handler share it) and lives at the callsite, so +different Triggers in the same component can forward different payloads (useful +for pickers). + +```tsx +type Props = { mutationFn: MutationFn } + +export const Create = createCallable(({ call, mutationFn }) => { + const [name, setName] = useState('') + const submit = useMutationFlow(call, mutationFn) + return +}) + +await Create.call({ + mutationFn: async (call, payload) => { // payload: { name: string } + await api.create(payload.name) + call.end(true) + }, +}) +``` diff --git a/skills/react-call/references/ssr-and-lazy.md b/skills/react-call/references/ssr-and-lazy.md new file mode 100644 index 0000000..593de3e --- /dev/null +++ b/skills/react-call/references/ssr-and-lazy.md @@ -0,0 +1,58 @@ +# SSR, Next.js / RSC, and lazy loading + +## SSR + +The setup is SSR-safe: both `createCallable` and the Root (``) are fine +to run/render on the server. But a **Call** is meant to be triggered by user +interaction, so `call()` is a **client-only** feature. + +- Rendering `` on the server: ✅ (the Stack is empty until hydration). +- Running `Confirm.call(...)` on the server: ❌ throws *"No found!"*. + +Keep `call()` out of server execution paths (call from event handlers / client +effects) and you're fine. + +## Next.js / RSC + +Mark the file where you run `createCallable(...)` as a Client Component — the +library uses `useSyncExternalStore`: + +```tsx +'use client' + +import { createCallable } from 'react-call' + +export const Confirm = createCallable(/* ... */) +``` + +Then `` mounts cleanly from any Server Component (e.g. +`app/layout.tsx`). The mount renders on the server; Calls only happen after +hydration on the client. + +## Lazy loading + +If a Callable carries a heavy payload (rich-text editor, chart lib, big form), +wrap it with `React.lazy` so the chunk ships only when the first Call fires. + +```tsx +import { createCallable } from 'react-call' +import { lazy, Suspense } from 'react' + +const Confirm = createCallable( + lazy(() => import('./Confirm')), // module must default-export the user component +) + +}> + + +``` + +- The lazy module **must default-export** the user component (React.lazy + requirement). +- The first Call waits for the chunk — pick a `fallback` that signals loading; + `null` works but the user sees nothing happen on click. +- Subsequent Calls are instant (chunk cached by the browser). + +The multi-Root throw fires at `call()` time (not at mount), which is what makes +`React.lazy` inside ``, StrictMode's double-invoke, and HMR re-mounts +compatible with a single Root. diff --git a/skills/react-call/references/types.md b/skills/react-call/references/types.md new file mode 100644 index 0000000..0132969 --- /dev/null +++ b/skills/react-call/references/types.md @@ -0,0 +1,69 @@ +# TypeScript types + +Most code never imports these — the generics on `createCallable` flow through. Import the named types when splitting a component +declaration out of the `createCallable(...)` call. + +```tsx +import type { UserComponent, CallContext } from 'react-call' +``` + +## Public types (named exports from `react-call`) + +| Type | Description | +| --- | --- | +| `CallFunction` | The `call()` method: `(props) => Promise`. | +| `UpsertFunction` | The `upsert()` method (same shape as `call`). | +| `CallContext` | The `call` prop your component receives: `{ end, ended, key, index, stackSize, root }`. | +| `PropsWithCall` | Your `Props` plus the `call` prop. | +| `UserComponent` | What you pass to `createCallable` — `FunctionComponent>`. | +| `Callable` | What `createCallable` returns — the Root component with `call`, `upsert`, `end`, `update` attached. | + +From `react-call/mutation-flow`: `MutationFn`, +`MutationCall` (`{ end }`), `Trigger`, and +`ChainTrigger` (the `.orEnd` variant when the MutationFn may be +undefined). + +## CallContext shape + +`call` exposes: `end(response)`, `ended` (boolean — true while the exit-animation +delay runs), `key` (stable per Call), `index` + `stackSize` (this Call's position +in the Stack), and `root` (the RootProps passed to ``). + +## Splitting the declaration + +```tsx +import { createCallable, type UserComponent } from 'react-call' + +interface Props { message: string } +type Response = boolean + +const ConfirmView: UserComponent = ({ call, message }) => ( +
{/* … */}
+) + +export const Confirm = createCallable(ConfirmView) +``` + +## v1 → v2 migration + +react-call v2 changed the public type surface. Migration is mechanical: + +- **`` is the canonical Root.** `` still works but is + soft-deprecated (no removal date) — prefer the bare form. +- **Flat named exports replace the `ReactCall` namespace:** + + | v1 | v2 | + | --- | --- | + | `ReactCall.Function` | `CallFunction` | + | `ReactCall.UpsertFunction` | `UpsertFunction` | + | `ReactCall.Context` | `CallContext` | + | `ReactCall.Props` | `PropsWithCall` | + | `ReactCall.UserComponent` | `UserComponent` | + | `ReactCall.Callable` | `Callable` | + +- **`CallContext` no longer exposes `promise`, `resolve`, or `isUpsert`.** Replace + `call.resolve(value)` with `call.end(value)`; the other two were internal. +- **The multi-Root error fires at `call()` time, not at mount** — tests that did + `expect(() => render(<>)).toThrow()` should assert at the + `call()` site instead. From 3f5b1ff7653612d5cbc2e9149a169732240f6d4b Mon Sep 17 00:00:00 2001 From: desko27 Date: Sun, 31 May 2026 20:04:59 +0200 Subject: [PATCH 2/7] Give the AI agent skill prominence in the README Add a hero link and a [!TIP] callout right after `npm install` so the skill surfaces at setup time, not buried before the v1 migration notes. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index f2a5dfb..f39f367 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@  ·  Examples gallery  ·  + 🤖 AI agent skill +  ·  Getting started

@@ -76,6 +78,10 @@ menus, pickers — any UI that conceptually returns a value to its caller. npm install react-call ``` +> [!TIP] +> **Using an AI assistant** (Claude Code, Cursor, …)? Install the [react-call skill](#ai-agent-skill) so it writes correct Callables — the single-Root rule, `call` vs `upsert`, mutation flow, SSR: +> `npx skills add desko27/react-call --skill react-call` + We'll setup a confirmation dialog, but you can setup any component to be callable. ## 1. ⚛️ Declare From caea3f84dc6178b00c25a960674bc303f43bd069 Mon Sep 17 00:00:00 2001 From: desko27 Date: Sun, 31 May 2026 20:09:29 +0200 Subject: [PATCH 3/7] Surface the agent skill in the site's get-started CTA Add a copyable `npx skills add` command box under the package InstallCommand on the landing CTA, plus a small CopyCommand component for one-off commands. --- sites/web/src/components/CopyCommand.tsx | 85 ++++++++++++++++++++++++ sites/web/src/pages/index.astro | 23 ++++++- 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 sites/web/src/components/CopyCommand.tsx diff --git a/sites/web/src/components/CopyCommand.tsx b/sites/web/src/components/CopyCommand.tsx new file mode 100644 index 0000000..624cc67 --- /dev/null +++ b/sites/web/src/components/CopyCommand.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react' + +interface CopyCommandProps { + command: string + label?: string +} + +// A single, fixed command with a copy button — mirrors the lower row of +// InstallCommand but without the package-manager tabs. Used for one-off +// commands like the agent-skill install. +export const CopyCommand = ({ command, label }: CopyCommandProps) => { + const [copied, setCopied] = useState(false) + + const copy = async () => { + try { + await navigator.clipboard.writeText(command) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + // clipboard unavailable + } + } + + return ( +
+ $ + + {command} + + +
+ ) +} diff --git a/sites/web/src/pages/index.astro b/sites/web/src/pages/index.astro index 06c01ce..1d62eb3 100644 --- a/sites/web/src/pages/index.astro +++ b/sites/web/src/pages/index.astro @@ -1,4 +1,5 @@ --- +import { CopyCommand } from '~/components/CopyCommand' import { InstallCommand } from '~/components/InstallCommand' import { Hero } from '~/components/landing/Hero' import { HowItLives } from '~/components/landing/HowItLives' @@ -102,7 +103,27 @@ const onDelete = async () => {
-
+ +
+

+ 🤖 Building with an AI assistant? +

+ + + What the skill does ↗ + +
+ +
Date: Sun, 31 May 2026 20:10:37 +0200 Subject: [PATCH 4/7] Drop the AI skill TIP from README getting started Keep the hero link and the dedicated 'AI agent skill' section; remove the callout under npm install. --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index f39f367..63025ad 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,6 @@ menus, pickers — any UI that conceptually returns a value to its caller. npm install react-call ``` -> [!TIP] -> **Using an AI assistant** (Claude Code, Cursor, …)? Install the [react-call skill](#ai-agent-skill) so it writes correct Callables — the single-Root rule, `call` vs `upsert`, mutation flow, SSR: -> `npx skills add desko27/react-call --skill react-call` - We'll setup a confirmation dialog, but you can setup any component to be callable. ## 1. ⚛️ Declare From de4f287672f0fd69d61e6932ecd842a50788894a Mon Sep 17 00:00:00 2001 From: desko27 Date: Sun, 31 May 2026 20:17:49 +0200 Subject: [PATCH 5/7] Experiment: toggle library/skill install in the hero Add a segmented control above the hero install box that swaps the package InstallCommand (npm/pnpm/yarn/bun) for the agent-skill install command. --- sites/web/src/components/landing/Hero.tsx | 4 +- .../src/components/landing/HeroInstall.tsx | 64 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 sites/web/src/components/landing/HeroInstall.tsx diff --git a/sites/web/src/components/landing/Hero.tsx b/sites/web/src/components/landing/Hero.tsx index 3aa9cf8..ebd0d73 100644 --- a/sites/web/src/components/landing/Hero.tsx +++ b/sites/web/src/components/landing/Hero.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { InstallCommand } from '../InstallCommand' import { HeroConfirm } from './HeroConfirm' +import { HeroInstall } from './HeroInstall' import { type Result, ResultBadge } from './ResultBadge' export const Hero = () => { @@ -32,7 +32,7 @@ export const Hero = () => {
- +

+
+ {TABS.map((tab) => { + const active = mode === tab.id + return ( + + ) + })} +
+ + {mode === 'lib' ? ( + + ) : ( + + )} +

+ ) +} From 2c01ec04c746d54cacfca7b50e0ae5e8c230963a Mon Sep 17 00:00:00 2001 From: desko27 Date: Mon, 1 Jun 2026 12:02:13 +0200 Subject: [PATCH 6/7] Refine hero install toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labels: 'Install' / '🤖 AI skill' - Subtler selector: text with a thin accent underline instead of a bordered pill - No height jump: both boxes share one grid cell, sizing to the taller (lib) box --- .../src/components/landing/HeroInstall.tsx | 50 ++++++++++++------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/sites/web/src/components/landing/HeroInstall.tsx b/sites/web/src/components/landing/HeroInstall.tsx index 7babf93..9313652 100644 --- a/sites/web/src/components/landing/HeroInstall.tsx +++ b/sites/web/src/components/landing/HeroInstall.tsx @@ -5,13 +5,13 @@ import { InstallCommand } from '../InstallCommand' type Mode = 'lib' | 'skill' const TABS: { id: Mode; label: string }[] = [ - { id: 'lib', label: 'Library' }, - { id: 'skill', label: 'AI skill' }, + { id: 'lib', label: 'Install' }, + { id: 'skill', label: '🤖 AI skill' }, ] const SKILL_COMMAND = 'npx skills add desko27/react-call --skill react-call' -// Hero install widget: a segmented control that swaps the package install +// Hero install widget: a subtle text toggle that swaps the package install // (InstallCommand, with its PM tabs) for the agent-skill install command. export const HeroInstall = () => { const [mode, setMode] = useState('lib') @@ -21,11 +21,7 @@ export const HeroInstall = () => {
{TABS.map((tab) => { const active = mode === tab.id @@ -37,11 +33,11 @@ export const HeroInstall = () => { aria-selected={active} onClick={() => setMode(tab.id)} className={` - rounded px-3 py-1 font-mono text-xs transition-colors + border-b-2 pb-0.5 transition-colors ${ active - ? 'bg-[var(--color-bg)] text-[var(--color-fg)]' - : 'text-[var(--color-fg-subtle)] hover:text-[var(--color-fg-muted)]' + ? 'border-[var(--color-accent)] text-[var(--color-fg)]' + : 'border-transparent text-[var(--color-fg-subtle)] hover:text-[var(--color-fg-muted)]' } `} > @@ -51,14 +47,30 @@ export const HeroInstall = () => { })}
- {mode === 'lib' ? ( - - ) : ( - - )} + {/* Both boxes share one grid cell so the widget always sizes to the + taller one (the lib box, with its PM tab row) — toggling never + shifts the layout. The hidden box stays in flow via `invisible`. */} +
+
+ +
+
+ +
+
) } From 6bcf8a6698c31555b86d111f47a5cce2ec359be2 Mon Sep 17 00:00:00 2001 From: desko27 Date: Mon, 1 Jun 2026 13:20:07 +0200 Subject: [PATCH 7/7] Fix skill command overflow in the hero on mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid stack's implicit column was max-content-sized, so the long skill command forced the track wider than the viewport and overflowed. Bound the column with grid-cols-1 (minmax(0,1fr)) and add min-w-0 to the cells so the box caps at the available width and scrolls horizontally inside — matching the install box and the bottom-of-landing command. Verified at 375px. --- sites/web/src/components/landing/HeroInstall.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/sites/web/src/components/landing/HeroInstall.tsx b/sites/web/src/components/landing/HeroInstall.tsx index 9313652..883e37e 100644 --- a/sites/web/src/components/landing/HeroInstall.tsx +++ b/sites/web/src/components/landing/HeroInstall.tsx @@ -49,10 +49,13 @@ export const HeroInstall = () => { {/* Both boxes share one grid cell so the widget always sizes to the taller one (the lib box, with its PM tab row) — toggling never - shifts the layout. The hidden box stays in flow via `invisible`. */} -
+ shifts the layout. The hidden box stays in flow via `invisible`. + `grid-cols-1` bounds the track to minmax(0,1fr) and `min-w-0` lets + the cell shrink below the command's width, so a long command scrolls + inside the box on narrow screens instead of overflowing the page. */} +
{