Compare commits

...
Author SHA1 Message Date
Patrick HallandPatrick 9bb82604f0 solid: add renderer implementation (#201)
* solid: add renderer implementation

This adds a Solid renderer with the same core capabilities as the
existing framework packages, including typed registry helpers,
providers, streaming hooks, docs, and example integration.

* fix: solid reactivity bugs in validation + state

---------

Co-authored-by: Patrick <phall@Patricks-MacBook-Pro.local>
2026-03-11 09:12:07 -05:00
github-actions[bot] caa90b8b84 chore: version packages (#204)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-11 02:19:24 -05:00
Chris Tate 54a1ecf817 Add v0.12.1 release changeset with mode renames and MCP fixes (#203)
This PR adds the changeset file to prepare for the v0.12.1 release, documenting important breaking changes and bug fixes.

## Changes

**@json-render/core**
- Renamed generation modes from `"generate"`/`"chat"` to `"standalone"`/`"inline"` (old names deprecated with warnings)

**@json-render/mcp**  
- Fixed React duplicate module error causing `useRef` to return null by adding Vite `resolve.dedupe` configuration
- Added `./build-app-html` export entry point

**Other improvements**
- Updated homepage URLs across all packages to https://json-render.dev
- Reorganized skills directory structure
- Added skills documentation page to web app

Fixes #198
2026-03-11 02:09:10 -05:00
Chris Tate dc9e9d17f3 rename modes (#197)
* rename modes

* improvements

* fixes

* fix homepage
2026-03-10 15:07:47 -05:00
128 changed files with 7490 additions and 206 deletions
+2 -1
View File
@@ -19,7 +19,8 @@
"@json-render/xstate",
"@json-render/image",
"@json-render/mcp",
"@json-render/svelte"
"@json-render/svelte",
"@json-render/solid"
]
],
"linked": [],
+1
View File
@@ -4,6 +4,7 @@
node_modules
.pnp
.pnp.js
.pnpm-store/
# Local env files
.env*
+118 -43
View File
@@ -21,6 +21,8 @@ npm install @json-render/core @json-render/react-email @react-email/components @
npm install @json-render/core @json-render/vue
# or for Svelte
npm install @json-render/core @json-render/svelte
# or for SolidJS
npm install @json-render/core @json-render/solid
```
## Why json-render?
@@ -30,7 +32,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React, Vue, Svelte (web), React Native (mobile) from the same catalog
- **Cross-Platform** - React, Vue, Svelte, Solid (web), React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start
@@ -91,9 +93,7 @@ const { registry } = defineRegistry(catalog, {
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>
{props.label}
</button>
<button onClick={() => emit("press")}>{props.label}</button>
),
},
});
@@ -113,24 +113,25 @@ function Dashboard({ spec }) {
## Packages
| Package | Description |
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
| Package | Description |
| --------------------------- | ---------------------------------------------------------------------- |
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
## Renderers
@@ -159,7 +160,7 @@ const spec = {
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
### Vue (UI)
@@ -199,6 +200,26 @@ const { registry } = defineRegistry(catalog, {
// <Renderer spec={spec} registry={registry} />
```
### Solid (UI)
```tsx
import { defineRegistry, Renderer } from "@json-render/solid";
import { schema } from "@json-render/solid/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
},
});
<Renderer spec={spec} registry={registry} />;
```
### shadcn/ui (Web)
```tsx
@@ -229,7 +250,7 @@ const { registry } = defineRegistry(catalog, {
},
});
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
### React Native (Mobile)
@@ -250,23 +271,40 @@ const catalog = defineCatalog(schema, {
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
### Remotion (Video)
```tsx
import { Player } from "@remotion/player";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
import {
Renderer,
schema,
standardComponentDefinitions,
} from "@json-render/remotion";
// Timeline spec format
const spec = {
composition: { id: "video", fps: 30, width: 1920, height: 1080, durationInFrames: 300 },
composition: {
id: "video",
fps: 30,
width: 1920,
height: 1080,
durationInFrames: 300,
},
tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
clips: [
{ id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90 }
{
id: "clip-1",
trackId: "main",
component: "TitleCard",
props: { title: "Hello" },
from: 0,
durationInFrames: 90,
},
],
audio: { tracks: [] }
audio: { tracks: [] },
};
<Player
@@ -276,7 +314,7 @@ const spec = {
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>
/>;
```
### React PDF (Documents)
@@ -287,7 +325,11 @@ import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
doc: {
type: "Document",
props: { title: "Invoice" },
children: ["page-1"],
},
"page-1": {
type: "Page",
props: { size: "A4" },
@@ -301,8 +343,14 @@ const spec = {
"table-1": {
type: "Table",
props: {
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
@@ -327,7 +375,11 @@ const catalog = defineCatalog(schema, {
const spec = {
root: "html-1",
elements: {
"html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
"html-1": {
type: "Html",
props: { lang: "en", dir: "ltr" },
children: ["head-1", "body-1"],
},
"head-1": { type: "Head", props: {}, children: [] },
"body-1": {
type: "Body",
@@ -336,11 +388,17 @@ const spec = {
},
"container-1": {
type: "Container",
props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
props: {
style: { maxWidth: "600px", margin: "0 auto", padding: "20px" },
},
children: ["heading-1", "text-1"],
},
"heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
"text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
"text-1": {
type: "Text",
props: { text: "Thanks for signing up." },
children: [],
},
},
};
@@ -425,8 +483,16 @@ Any prop value can be data-driven using expressions:
{
"type": "Icon",
"props": {
"name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
"name": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "home",
"$else": "home-outline"
},
"color": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "#007AFF",
"$else": "#8E8E93"
}
}
}
```
@@ -445,7 +511,10 @@ Components can trigger actions, including the built-in `setState` action:
```json
{
"type": "Pressable",
"props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
"props": {
"action": "setState",
"actionParams": { "statePath": "/activeTab", "value": "home" }
},
"children": ["home-icon"]
}
```
@@ -459,9 +528,15 @@ React to state changes by triggering actions:
```json
{
"type": "Select",
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
"props": {
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
}
}
```
@@ -486,7 +561,7 @@ pnpm dev
- Chat Example: run `pnpm dev` in `examples/chat`
- Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue + Svelte): run `pnpm dev` in `examples/vite-renderers`
- Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native`
## How It Works
@@ -496,7 +571,7 @@ flowchart LR
A[User Prompt] --> B[AI + Catalog]
B --> C[JSON Spec]
C --> D[Renderer]
B -.- E([guardrailed])
C -.- F([predictable])
D -.- G([streamed])
+9
View File
@@ -1,5 +1,14 @@
# web
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/codegen@0.12.1
- @json-render/react@0.12.1
## 0.1.5
### Patch Changes
+12 -12
View File
@@ -3,7 +3,7 @@ export const metadata = pageMetadata("docs/ai-sdk")
# AI SDK Integration
Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Generate** (standalone UI) and **Chat** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Standalone** (standalone UI) and **Inline** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
## Installation
@@ -11,9 +11,9 @@ Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless str
npm install ai @ai-sdk/react
```
## Generate Mode
## Standalone Mode
In generate mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators.
In standalone mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators.
### API Route
@@ -73,9 +73,9 @@ function GenerativeUI() {
}
```
## Chat Mode
## Inline Mode
In chat mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants.
In inline mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants.
### API Route
@@ -96,7 +96,7 @@ export async function POST(req: Request) {
const result = streamText({
model: yourModel,
system: catalog.prompt({ mode: "chat" }),
system: catalog.prompt({ mode: "inline" }),
messages,
});
@@ -182,13 +182,13 @@ const systemPrompt = catalog.prompt({
});
```
### Chat Mode Prompt
### Inline Mode Prompt
```typescript
const chatPrompt = catalog.prompt({ mode: "chat" });
const inlinePrompt = catalog.prompt({ mode: "inline" });
```
In chat mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed.
In inline mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed.
## Which Mode?
@@ -197,8 +197,8 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
<thead>
<tr>
<th></th>
<th>Generate</th>
<th>Chat</th>
<th>Standalone</th>
<th>Inline</th>
</tr>
</thead>
<tbody>
@@ -215,7 +215,7 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
<tr>
<td>System prompt</td>
<td><code>catalog.prompt()</code></td>
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
<td><code>{"catalog.prompt({ mode: \"inline\" })"}</code></td>
</tr>
<tr>
<td>Stream utility</td>
+4 -4
View File
@@ -75,7 +75,7 @@ interface Catalog {
interface PromptOptions {
system?: string; // Custom system message intro
customRules?: string[]; // Additional rules to append
mode?: "generate" | "chat"; // Output mode (default: "generate")
mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
}
interface SpecValidationResult<T> {
@@ -370,7 +370,7 @@ Most users should use `pipeJsonRender()` instead, which wraps this transform for
### createMixedStreamParser
Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
Parse a mixed stream of text and JSONL patches (used for Inline mode):
```typescript
import { createMixedStreamParser } from '@json-render/core';
@@ -389,7 +389,7 @@ parser.flush();
### pipeJsonRender
Pipe an AI SDK `UIMessageStream` through the json-render transform. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. Used in Chat mode API routes.
Pipe an AI SDK `UIMessageStream` through the json-render transform. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. Used in Inline mode API routes.
```typescript
import { pipeJsonRender } from '@json-render/core';
@@ -403,7 +403,7 @@ const stream = createUIMessageStream({
return createUIMessageStreamResponse({ stream });
```
See [Generation Modes](/docs/generation-modes) for full Chat mode setup.
See [Generation Modes](/docs/generation-modes) for full Inline mode setup.
### SpecStream Types
+201
View File
@@ -0,0 +1,201 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/api/solid");
# @json-render/solid
SolidJS components, providers, and hooks for rendering json-render specs.
## Installation
<PackageInstall packages="@json-render/core @json-render/solid" />
Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
<PackageInstall packages="solid-js zod" />
## Providers
### StateProvider
```tsx
<StateProvider
initialState={{}}
onStateChange={(changes) => console.log(changes)}
>
{/* children */}
</StateProvider>
```
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>store</code>
</td>
<td>
<code>StateStore</code>
</td>
<td>
External store (controlled mode). When provided,{" "}
<code>initialState</code> and <code>onStateChange</code> are ignored.
</td>
</tr>
<tr>
<td>
<code>initialState</code>
</td>
<td>
<code>Record&lt;string, unknown&gt;</code>
</td>
<td>Initial state model for uncontrolled mode.</td>
</tr>
<tr>
<td>
<code>onStateChange</code>
</td>
<td>
<code>
{"(changes: Array<{ path: string; value: unknown }>) => void"}
</code>
</td>
<td>Called for uncontrolled state updates.</td>
</tr>
</tbody>
</table>
### ActionProvider
```tsx
<ActionProvider
handlers={{ submit: async (params) => {} }}
navigate={(path) => {}}
>
{/* children */}
</ActionProvider>
```
### VisibilityProvider
```tsx
<VisibilityProvider>{/* children */}</VisibilityProvider>
```
### ValidationProvider
```tsx
<ValidationProvider customFunctions={{ custom: (value) => Boolean(value) }}>
{/* children */}
</ValidationProvider>
```
### JSONUIProvider
Combined provider wrapper for state, visibility, validation, and actions.
```tsx
<JSONUIProvider
registry={registry}
initialState={{}}
handlers={handlers}
validationFunctions={validationFunctions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
## defineRegistry
Create a typed component registry and action helpers from a catalog.
```tsx
const { registry, handlers, executeAction } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
},
actions: {
submit: async (params, setState, state) => {
// custom action logic
},
},
});
```
## Components
### Renderer
```tsx
<Renderer spec={spec} registry={registry} loading={false} />
```
Renders a `Spec` tree using your registry.
### createRenderer
Build an app-level renderer from catalog + components:
```tsx
const AppRenderer = createRenderer(catalog, {
Card: (renderProps) => <div>{renderProps.children}</div>,
});
<AppRenderer spec={spec} state={{}} onAction={(name, params) => {}} />;
```
## Hooks
- `useStateStore()`
- `useStateValue(path)` - returns an accessor
- `useStateBinding(path)` - returns `[Accessor<T | undefined>, setValue]`
- `useVisibility()` / `useIsVisible(condition)`
- `useActions()` / `useAction(binding)`
- `useValidation()` / `useOptionalValidation()`
- `useFieldValidation(path, config)` - returns accessor-backed `state`, `errors`, and `isValid`
- `useBoundProp(value, bindingPath)`
- `useUIStream(options)`
- `useChatUI(options)`
## Built-in Actions
`ActionProvider` handles these built-in actions:
- `setState`
- `pushState`
- `removeState`
- `validateForm`
## Component Props
Registry components receive:
```ts
interface ComponentRenderProps<P = Record<string, unknown>> {
element: UIElement<string, P>;
children?: JSX.Element;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}
```
Use `emit("event")` to dispatch event bindings. Use `on("event")` to access `EventHandle` metadata (`bound`, `shouldPreventDefault`, `emit`).
## Reactivity Notes
- Keep changing reads in JSX expressions, `createMemo`, or `createEffect`.
- Avoid props destructuring in component signatures when you need live updates.
- `StateProvider` and other contexts expose getter-backed values so consumers read live signals.
- `useStateValue`, `useStateBinding`, and `useFieldValidation` expose reactive accessors; call them as functions.
@@ -341,6 +341,8 @@ February 2026
### New: Chat Mode (Inline GenUI)
> **Note:** These modes were renamed in v0.12.1 — "Generate" is now "Standalone" and "Chat" is now "Inline". The old names are accepted as deprecated aliases.
json-render now supports two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets the AI respond conversationally with embedded UI specs, ideal for chatbots and copilot experiences.
```typescript
@@ -3,15 +3,15 @@ export const metadata = pageMetadata("docs/generation-modes")
# Generation Modes
json-render supports two modes for AI-generated UI: **Generate mode** for standalone UI and **Chat mode** for inline UI within a conversation.
json-render supports two modes for AI-generated UI: **Standalone mode** for standalone UI and **Inline mode** for inline UI within a conversation.
The mode controls how the AI formats its output and how your app processes the stream. The underlying JSONL patch format is the same in both modes.
<GenerationModesDiagram />
## Generate Mode (Standalone)
## Standalone Mode
In generate mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec.
In standalone mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec.
This is the default mode and is ideal for:
@@ -25,7 +25,7 @@ This is the default mode and is ideal for:
```typescript
import { streamText } from "ai";
// Generate mode is the default (no mode option needed)
// Standalone mode is the default (no mode option needed)
const systemPrompt = catalog.prompt({
customRules: [
"Use Card as root for forms and small UIs.",
@@ -74,9 +74,9 @@ The AI outputs only JSONL — one patch per line, no surrounding text:
{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Sign In"}}}
```
## Chat Mode (Inline)
## Inline Mode
In chat mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions).
In inline mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions).
This is ideal for:
@@ -92,8 +92,8 @@ import { streamText } from "ai";
import { pipeJsonRender } from "@json-render/core";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
// Enable chat mode
const systemPrompt = catalog.prompt({ mode: "chat" });
// Enable inline mode
const systemPrompt = catalog.prompt({ mode: "inline" });
const result = streamText({
model: yourModel,
@@ -180,8 +180,8 @@ If the user asks a simple question ("what does BTC stand for?"), the AI replies
<thead>
<tr>
<th />
<th>Generate</th>
<th>Chat</th>
<th>Standalone</th>
<th>Inline</th>
</tr>
</thead>
<tbody>
@@ -198,7 +198,7 @@ If the user asks a simple question ("what does BTC stand for?"), the AI replies
<tr>
<td>System prompt</td>
<td><code>{"catalog.prompt()"}</code></td>
<td><code>{'catalog.prompt({ mode: "chat" })'}</code></td>
<td><code>{'catalog.prompt({ mode: "inline" })'}</code></td>
</tr>
<tr>
<td>Stream utility</td>
+26 -1
View File
@@ -309,10 +309,35 @@ const catalog = defineCatalog(schema, {
const prompt = catalog.prompt();
// Chat mode prompt
// Inline mode prompt (formerly "chat")
const inlinePrompt = catalog.prompt({ mode: "inline" });
```
## Generation Modes
The generation mode values passed to `catalog.prompt()` have been renamed for clarity:
- `"generate"` is now `"standalone"`
- `"chat"` is now `"inline"`
The old names are accepted as deprecated aliases, so existing code will continue to work. Update when convenient.
**Before:**
```typescript
const prompt = catalog.prompt({ mode: "generate" });
const chatPrompt = catalog.prompt({ mode: "chat" });
```
**After:**
```typescript
const prompt = catalog.prompt({ mode: "standalone" });
const inlinePrompt = catalog.prompt({ mode: "inline" });
```
The default mode (when no `mode` option is provided) is `"standalone"`, which behaves identically to the previous `"generate"` default.
## Validation
`ValidationCheck` now uses `type` instead of `fn`, `ValidationProvider` uses `customFunctions` instead of `functions`, and `useFieldValidation` takes a config object instead of a checks array.
+1 -1
View File
@@ -91,7 +91,7 @@ The result is a native UI built from your own components — not an iframe, not
- **[Streaming](/docs/streaming)** — Render progressively as the AI responds. Each JSONL patch adds to the spec and the UI updates in real time.
- **[Data Binding](/docs/data-binding)** — Bind props to runtime data with `$state` paths, repeat elements over arrays, and wire two-way input bindings.
- **[Visibility](/docs/visibility)** — Show or hide elements based on state conditions. The AI can generate conditional UIs without writing logic.
- **[Generation Modes](/docs/generation-modes)** — Generate standalone UI (playground/builder) or inline UI within a chat conversation.
- **[Generation Modes](/docs/generation-modes)** — Standalone mode for full-page generated UI or inline mode for UI embedded in a conversation.
## Next
+85 -14
View File
@@ -1,5 +1,5 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/renderers")
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/renderers");
# Renderers
@@ -22,37 +22,65 @@ All renderers share the same workflow:
<tbody>
<tr>
<td>React</td>
<td><code>@json-render/react</code></td>
<td>
<code>@json-render/react</code>
</td>
<td>React component tree</td>
</tr>
<tr>
<td>Vue</td>
<td><code>@json-render/vue</code></td>
<td>
<code>@json-render/vue</code>
</td>
<td>Vue 3 component tree</td>
</tr>
<tr>
<td>Svelte</td>
<td>
<code>@json-render/svelte</code>
</td>
<td>Svelte 5 component tree</td>
</tr>
<tr>
<td>Solid</td>
<td>
<code>@json-render/solid</code>
</td>
<td>SolidJS component tree</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td><code>@json-render/shadcn</code></td>
<td>
<code>@json-render/shadcn</code>
</td>
<td>Pre-built Radix UI + Tailwind components (uses React renderer)</td>
</tr>
<tr>
<td>React Native</td>
<td><code>@json-render/react-native</code></td>
<td>
<code>@json-render/react-native</code>
</td>
<td>Native mobile views</td>
</tr>
<tr>
<td>Image</td>
<td><code>@json-render/image</code></td>
<td>
<code>@json-render/image</code>
</td>
<td>SVG / PNG (via Satori)</td>
</tr>
<tr>
<td>React PDF</td>
<td><code>@json-render/react-pdf</code></td>
<td>
<code>@json-render/react-pdf</code>
</td>
<td>PDF documents</td>
</tr>
<tr>
<td>Remotion</td>
<td><code>@json-render/remotion</code></td>
<td>
<code>@json-render/remotion</code>
</td>
<td>Video compositions</td>
</tr>
</tbody>
@@ -67,7 +95,7 @@ import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details.
@@ -90,6 +118,42 @@ const { registry } = defineRegistry(catalog, {
Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details.
## Svelte
Svelte 5 renderer with runes-compatible context helpers, visibility conditions, actions, and streaming support.
```typescript
import { defineRegistry, Renderer } from "@json-render/svelte";
import { schema } from "@json-render/svelte/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => /* Svelte snippet */,
},
});
```
See the [@json-render/svelte API reference](/docs/api/svelte) for details.
## Solid
SolidJS renderer with fine-grained reactivity, state bindings, validation, visibility, and event-driven actions.
```tsx
import { defineRegistry, Renderer } from "@json-render/solid";
import { schema } from "@json-render/solid/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
},
});
<Renderer spec={spec} registry={registry} />;
```
See the [@json-render/solid API reference](/docs/api/solid) for details.
## shadcn/ui
36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed.
@@ -125,7 +189,10 @@ Render specs as native mobile views. Includes 25+ standard components and standa
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-native/schema";
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/react-native/catalog";
import { defineRegistry, Renderer } from "@json-render/react-native";
const catalog = defineCatalog(schema, {
@@ -134,7 +201,7 @@ const catalog = defineCatalog(schema, {
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />
<Renderer spec={spec} registry={registry} />;
```
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
@@ -159,7 +226,11 @@ See the [@json-render/image API reference](/docs/api/image) for details.
Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file.
```typescript
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
import {
renderToBuffer,
renderToStream,
renderToFile,
} from "@json-render/react-pdf";
const buffer = await renderToBuffer(spec);
const stream = await renderToStream(spec);
@@ -185,7 +256,7 @@ import { Renderer } from "@json-render/remotion";
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>
/>;
```
Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects.
+8 -2
View File
@@ -1,6 +1,6 @@
import { pageMetadata } from "@/lib/page-metadata"
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/skills")
export const metadata = pageMetadata("docs/skills");
# Skills
@@ -18,6 +18,7 @@ json-render ships with skills that teach AI coding agents how to use each packag
- **remotion** — Remotion renderer for video generation from JSON timeline specs.
- **vue** — Vue 3 renderer for Vue component trees.
- **svelte** — Svelte 5 renderer for Svelte component trees.
- **solid** — SolidJS renderer for fine-grained reactive component trees.
- **codegen** — Code generation utilities for building custom exporters.
- **mcp** — MCP Apps integration for Claude, ChatGPT, Cursor, and VS Code.
- **redux** — Redux adapter for json-render's `StateStore` interface.
@@ -38,6 +39,7 @@ npx skills add vercel-labs/json-render --skill image
npx skills add vercel-labs/json-render --skill remotion
npx skills add vercel-labs/json-render --skill vue
npx skills add vercel-labs/json-render --skill svelte
npx skills add vercel-labs/json-render --skill solid
npx skills add vercel-labs/json-render --skill codegen
npx skills add vercel-labs/json-render --skill mcp
npx skills add vercel-labs/json-render --skill redux
@@ -88,6 +90,10 @@ Teaches agents how to render JSON specs as Vue 3 component trees. Covers the Vue
Teaches agents how to render JSON specs as Svelte 5 component trees. Covers the Svelte renderer API and component registration.
## solid
Teaches agents how to render JSON specs as SolidJS component trees. Covers Solid-specific reactive patterns, provider wiring, bindings, actions, and streaming.
## codegen
Teaches agents how to use code generation utilities to export UI specs as framework-specific source code. Covers the codegen pipeline and custom exporter creation.
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/shadcn, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, image, remotion, vue, svelte, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
@@ -19,11 +19,11 @@ function FormUI() {
);
}
function ChatModeDiagram() {
function InlineModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
Chat Mode
Inline Mode
</div>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
{/* Chat area */}
@@ -69,11 +69,11 @@ function ChatModeDiagram() {
);
}
function GenerateModeDiagram() {
function StandaloneModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
Generate Mode
Standalone Mode
</div>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-row">
{/* Left panel - prompt */}
@@ -104,10 +104,10 @@ export function GenerationModesDiagram() {
<div className="not-prose my-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="h-[280px]">
<ChatModeDiagram />
<InlineModeDiagram />
</div>
<div className="h-[280px]">
<GenerateModeDiagram />
<StandaloneModeDiagram />
</div>
</div>
</div>
+2 -1
View File
@@ -92,7 +92,7 @@ export const docsNavigation: NavSection[] = [
external: true,
},
{
title: "Renders with Vite (Vue / React / Svelte)",
title: "Renders with Vite (Vue / React / Svelte / Solid)",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
external: true,
},
@@ -133,6 +133,7 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
{ title: "@json-render/solid", href: "/docs/api/solid" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
{ title: "@json-render/mcp", href: "/docs/api/mcp" },
{ title: "@json-render/redux", href: "/docs/api/redux" },
+1
View File
@@ -43,6 +43,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/vue": "@json-render/vue API",
"docs/api/solid": "@json-render/solid API",
"docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-email": "@json-render/react-email API",
"docs/api/react-native": "@json-render/react-native API",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"private": true,
"license": "Apache-2.0",
+9
View File
@@ -1,5 +1,14 @@
# example-chat
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
- @json-render/shadcn@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -130,7 +130,7 @@ When the user asks for a quiz, test, or Q&A, build an interactive experience:
4. You can also add a final score section that becomes visible when all questions are submitted.
${explorerCatalog.prompt({
mode: "chat",
mode: "inline",
customRules: [
"NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
"Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-chat",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"private": true,
"scripts": {
+9
View File
@@ -1,5 +1,14 @@
# example-dashboard
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/codegen@0.12.1
- @json-render/react@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-dashboard",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-image
## 0.1.3
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/image@0.12.1
## 0.1.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-image",
"version": "0.1.2",
"version": "0.1.3",
"type": "module",
"private": true,
"scripts": {
+10
View File
@@ -1,5 +1,15 @@
# example-mcp
## 0.1.2
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/mcp@0.12.1
- @json-render/react@0.12.1
- @json-render/shadcn@0.12.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-mcp",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
+9
View File
@@ -1,5 +1,14 @@
# example-no-ai
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
- @json-render/shadcn@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-no-ai",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-react-email
## 0.1.2
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react-email@0.12.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-email",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-react-native
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react-native@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.5",
"version": "0.1.6",
"private": true,
"main": "expo-router/entry",
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-react-pdf
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react-pdf@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-pdf",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-remotion
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/remotion@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-remotion",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"private": true,
"scripts": {
@@ -1,5 +1,13 @@
# com.example.json-render-demo
## 0.0.7
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
## 0.0.6
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-demo",
"version": "0.0.6",
"version": "0.0.7",
"description": "Test",
"private": true,
"license": "~~proprietary~~",
@@ -1,5 +1,13 @@
# com.example.json-render-fullpage-demo
## 0.0.7
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
## 0.0.6
### Patch Changes
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.6",
"version": "0.0.7",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
+8
View File
@@ -1,5 +1,13 @@
# svelte-chat
## 0.0.3
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/svelte@0.12.1
## 0.0.2
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "svelte-chat",
"private": true,
"version": "0.0.2",
"version": "0.0.3",
"type": "module",
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -62,7 +62,7 @@ INPUT COMPONENTS:
- Button: Clickable button. Use on.press to trigger actions.
${explorerCatalog.prompt({
mode: "chat",
mode: "inline",
customRules: [
"NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
"Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
+8
View File
@@ -1,5 +1,13 @@
# example-svelte
## 0.1.3
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/svelte@0.12.1
## 0.1.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-svelte",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"type": "module",
"scripts": {
+10
View File
@@ -1,5 +1,15 @@
# vite-renderers
## 0.1.4
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
- @json-render/svelte@0.12.1
- @json-render/vue@0.12.1
## 0.1.3
### Patch Changes
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "vite-renderers",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"type": "module",
"scripts": {
@@ -12,10 +12,12 @@
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/solid": "workspace:*",
"@json-render/svelte": "workspace:*",
"@json-render/vue": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"solid-js": "^1.9.11",
"svelte": "^5.49.2",
"vue": "^3.5.29",
"zod": "4.3.6"
@@ -27,6 +29,7 @@
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
import "./shared/styles.css";
import { demoSpec } from "./spec";
type Renderer = "vue" | "react" | "svelte";
type Renderer = "vue" | "react" | "svelte" | "solid";
const container = document.getElementById("renderer-root") as HTMLElement;
@@ -18,10 +18,14 @@ async function switchTo(renderer: Renderer) {
const mod = await import("./react/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
} else if (renderer === "svelte") {
const mod = await import("./svelte/mount.ts");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
const mod = await import("./solid/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
}
}
+14 -1
View File
@@ -123,7 +123,9 @@ export const components: Components<AppCatalog> = {
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: "Rendered with Svelte"}
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
</span>
),
@@ -164,6 +166,17 @@ export const components: Components<AppCatalog> = {
>
Svelte
</button>
<button
onClick={() => emit("pressSolid")}
className={[
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Solid
</button>
</div>
</div>
),
@@ -65,7 +65,7 @@ export const catalogDef = {
props: z.object({ renderer: z.string() }),
slots: [],
description:
"Segmented tab control for switching between Vue, React, and Svelte renderers",
"Segmented tab control for switching between Vue, React, Svelte, and Solid renderers",
},
RendererBadge: {
props: z.object({ renderer: z.string() }),
@@ -99,5 +99,9 @@ export const catalogDef = {
params: z.object({}),
description: "Switch to the Svelte renderer",
},
switchToSolid: {
params: z.object({}),
description: "Switch to the Solid renderer",
},
},
};
@@ -10,6 +10,7 @@ export const actionStubs = {
switchToVue: async () => {},
switchToReact: async () => {},
switchToSvelte: async () => {},
switchToSolid: async () => {},
};
/** Creates action handlers that close over the state store's get/set */
@@ -52,5 +53,10 @@ export function makeHandlers(get: Get, set: Set) {
new CustomEvent("switch-renderer", { detail: "svelte" }),
);
},
switchToSolid: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "solid" }),
);
},
};
}
+40 -9
View File
@@ -11,9 +11,15 @@
align-items: center;
}
.json-render-stack--align-start { align-items: flex-start; }
.json-render-stack--align-center { align-items: center; }
.json-render-stack--align-end { align-items: flex-end; }
.json-render-stack--align-start {
align-items: flex-start;
}
.json-render-stack--align-center {
align-items: center;
}
.json-render-stack--align-end {
align-items: flex-end;
}
/* ---- Card ----------------------------------------------------------------- */
@@ -50,12 +56,22 @@
color: #111827;
}
.json-render-text--sm { font-size: 12px; }
.json-render-text--lg { font-size: 16px; }
.json-render-text--xl { font-size: 24px; }
.json-render-text--sm {
font-size: 12px;
}
.json-render-text--lg {
font-size: 16px;
}
.json-render-text--xl {
font-size: 24px;
}
.json-render-text--medium { font-weight: 500; }
.json-render-text--bold { font-weight: 700; }
.json-render-text--medium {
font-weight: 500;
}
.json-render-text--bold {
font-weight: 700;
}
/* ---- Button --------------------------------------------------------------- */
@@ -211,7 +227,7 @@
transition: background 0.15s;
}
.json-render-renderer-tab:first-child {
.json-render-renderer-tab:not(:last-child) {
border-right: 1px solid #e5e7eb;
}
@@ -261,3 +277,18 @@
background-color: #ff3e00;
color: white;
}
.renderer-solid .json-render-renderer-badge {
color: #2c4f7c;
background-color: #2c4f7c18;
border-color: #2c4f7c40;
}
.renderer-solid .json-render-renderer-dot {
background-color: #2c4f7c;
}
.renderer-solid .json-render-renderer-tab--active {
background-color: #2c4f7c;
color: white;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Spec } from "@json-render/core";
import { StateProvider } from "@json-render/solid";
import { DemoRenderer } from "./DemoRenderer";
export function App(props: { initialRenderer?: string; spec: Spec }) {
const renderer = props.initialRenderer ?? "solid";
const initialState = {
...props.spec.state,
renderer,
};
return (
<div class={`renderer-${renderer}`}>
<StateProvider initialState={initialState}>
<DemoRenderer spec={props.spec} />
</StateProvider>
</div>
);
}
@@ -0,0 +1,32 @@
import type { Spec } from "@json-render/core";
import {
ActionProvider,
ValidationProvider,
VisibilityProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/solid";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const { registry } = defineRegistry(catalog, {
components,
actions: actionStubs,
});
export function DemoRenderer(props: { spec: Spec }) {
const stateStore = useStateStore();
const handlers = makeHandlers(stateStore.get, stateStore.set);
return (
<ActionProvider handlers={handlers}>
<VisibilityProvider>
<ValidationProvider>
<Renderer spec={props.spec} registry={registry} />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
);
}
@@ -0,0 +1,5 @@
import { schema } from "@json-render/solid/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
@@ -0,0 +1,17 @@
import { render } from "solid-js/web";
import type { Spec } from "@json-render/core";
import { App } from "./App";
let dispose: (() => void) | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
dispose = render(
() => <App initialRenderer={renderer} spec={spec} />,
container,
);
}
export function unmount() {
dispose?.();
dispose = null;
}
@@ -0,0 +1,194 @@
import type { Components } from "@json-render/solid";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: (renderProps) => (
<div
class={[
"json-render-stack",
renderProps.props.direction === "horizontal" &&
"json-render-stack--horizontal",
renderProps.props.align &&
`json-render-stack--align-${renderProps.props.align}`,
]
.filter(Boolean)
.join(" ")}
style={{
gap: renderProps.props.gap ? `${renderProps.props.gap}px` : undefined,
padding: renderProps.props.padding
? `${renderProps.props.padding}px`
: undefined,
}}
>
{renderProps.children}
</div>
),
Card: (renderProps) => (
<div class="json-render-card">
{renderProps.props.title && (
<div class="json-render-card-title-wrap">
<h2 class="json-render-card-title">{renderProps.props.title}</h2>
</div>
)}
{renderProps.props.subtitle && (
<p class="json-render-card-subtitle">{renderProps.props.subtitle}</p>
)}
{renderProps.children}
</div>
),
Text: (renderProps) => (
<span
class={[
"json-render-text",
renderProps.props.size !== "md" &&
renderProps.props.size &&
`json-render-text--${renderProps.props.size}`,
renderProps.props.weight !== "normal" &&
renderProps.props.weight &&
`json-render-text--${renderProps.props.weight}`,
]
.filter(Boolean)
.join(" ")}
style={renderProps.props.color ? { color: renderProps.props.color } : {}}
>
{String(renderProps.props.content ?? "")}
</span>
),
Button: (renderProps) => (
<button
disabled={renderProps.props.disabled}
onClick={() => renderProps.emit("press")}
class={[
"json-render-button",
renderProps.props.variant &&
`json-render-button--${renderProps.props.variant}`,
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.label}
</button>
),
Badge: (renderProps) => (
<span
class="json-render-badge"
style={
renderProps.props.color
? {
"background-color": `${renderProps.props.color}20`,
color: renderProps.props.color,
"border-color": `${renderProps.props.color}40`,
}
: {}
}
>
{renderProps.props.label}
</span>
),
ListItem: (renderProps) => (
<div
onClick={() => renderProps.emit("press")}
class={[
"json-render-list-item",
renderProps.props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" ")}
>
<div
class={[
"json-render-list-item-check",
renderProps.props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.completed ? "✓" : ""}
</div>
<span
class={[
"json-render-list-item-text",
renderProps.props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.title}
</span>
</div>
),
RendererBadge: (renderProps) => (
<span class="json-render-renderer-badge">
<span class="json-render-renderer-dot" />
{renderProps.props.renderer === "vue"
? "Rendered with Vue"
: renderProps.props.renderer === "react"
? "Rendered with React"
: renderProps.props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
</span>
),
RendererTabs: (renderProps) => (
<div class="json-render-renderer-tabs-wrapper">
<span class="json-render-renderer-tabs-label">Render</span>
<div class="json-render-renderer-tabs">
<button
onClick={() => renderProps.emit("pressVue")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "vue" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Vue
</button>
<button
onClick={() => renderProps.emit("pressReact")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "react" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
React
</button>
<button
onClick={() => renderProps.emit("pressSvelte")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "svelte" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Svelte
</button>
<button
onClick={() => renderProps.emit("pressSolid")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "solid" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Solid
</button>
</div>
</div>
),
};
+2 -1
View File
@@ -10,7 +10,7 @@ export const demoSpec: Spec = {
{
id: 2,
title:
"Try @json-render/vue, @json-render/react, and @json-render/svelte",
"Try @json-render/vue, @json-render/react, @json-render/svelte, and @json-render/solid",
completed: false,
},
{ id: 3, title: "Build something awesome", completed: false },
@@ -49,6 +49,7 @@ export const demoSpec: Spec = {
pressVue: { action: "switchToVue" },
pressReact: { action: "switchToReact" },
pressSvelte: { action: "switchToSvelte" },
pressSolid: { action: "switchToSolid" },
},
},
@@ -12,5 +12,7 @@
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: "Rendered with Svelte"}
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
</span>
@@ -42,5 +42,16 @@
.join(" ")}>
Svelte
</button>
<button
type="button"
onclick={() => emit("pressSolid")}
class={[
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}>
Solid
</button>
</div>
</div>
+16 -1
View File
@@ -132,7 +132,9 @@ export const components: Components<AppCatalog> = {
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: "Rendered with Svelte",
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid",
]),
RendererTabs: ({ props, emit }) =>
@@ -178,6 +180,19 @@ export const components: Components<AppCatalog> = {
},
"Svelte",
),
h(
"button",
{
onClick: () => emit("pressSolid"),
class: [
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"Solid",
),
]),
]),
};
+7 -1
View File
@@ -2,7 +2,13 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import react from "@vitejs/plugin-react";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [svelte(), vue(), react({ include: /\.tsx$/ })],
plugins: [
svelte(),
vue(),
react({ include: /src\/react\/.*\.tsx$/ }),
solid({ include: [/src\/solid\/.*\.tsx$/, /packages\/solid\/.*\.tsx$/] }),
],
});
+8
View File
@@ -1,5 +1,13 @@
# example-vue
## 0.1.4
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/vue@0.12.1
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-vue",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
+11 -1
View File
@@ -32,6 +32,7 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@solidjs/testing-library": "^0.8.10",
"@testing-library/svelte": "^5.2.0",
"@types/react": "^19.2.3",
"husky": "^9.1.7",
@@ -40,9 +41,11 @@
"prettier": "^3.7.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"solid-js": "^1.9.11",
"svelte": "^5.0.0",
"turbo": "^2.7.4",
"typescript": "5.9.2",
"vite-plugin-solid": "^2.11.10",
"vitest": "^4.0.17"
},
"packageManager": "pnpm@10.29.3",
@@ -51,5 +54,12 @@
},
"lint-staged": {
"*.{ts,tsx}": "prettier --write"
}
},
"workspaces": [
"apps/*",
"examples/*",
"examples/stripe-app/*",
"packages/*",
"tests/*"
]
}
+7
View File
@@ -1,5 +1,12 @@
# @json-render/codegen
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
@@ -15,7 +15,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/codegen"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+17
View File
@@ -1,5 +1,22 @@
# @json-render/core
## 0.12.1
### Patch Changes
- 54a1ecf: Rename generation modes and fix MCP React duplicate module error.
### Changed:
- **`@json-render/core`** — Renamed generation modes from `"generate"` / `"chat"` to `"standalone"` / `"inline"`. The old names still work but emit a deprecation warning.
### Fixed:
- **`@json-render/mcp`** — Resolved React duplicate module error (`useRef` returning null) by adding `resolve.dedupe` Vite configuration. Added `./build-app-html` export entry point.
### Other:
- Updated `homepage` URLs across all packages to point to `https://json-render.dev`.
- Reorganized skills directory structure for cleaner naming.
- Added skills documentation page to the web app.
## 0.12.0
### Minor Changes
+1 -1
View File
@@ -227,7 +227,7 @@ Schema options:
| `ActionBinding` | Action binding with `action`, `params`, `confirm`, `preventDefault`, etc. |
| `BuiltInAction` | Built-in action definition with `name` and `description` |
### Chat Mode (Mixed Streams)
### Inline Mode (Mixed Streams)
| Export | Purpose |
|--------|---------|
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
@@ -19,7 +19,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/core"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+35 -3
View File
@@ -257,7 +257,7 @@ describe("catalog.prompt", () => {
expect(prompt).toContain("Keep UIs simple");
});
it("generates chat mode prompt when mode is chat", () => {
it("generates inline mode prompt when mode is inline", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
@@ -268,13 +268,13 @@ describe("catalog.prompt", () => {
},
actions: {},
});
const prompt = catalog.prompt({ mode: "chat" });
const prompt = catalog.prompt({ mode: "inline" });
expect(prompt).toContain("```spec");
expect(prompt).toContain("conversationally");
expect(prompt).toContain("text + JSONL");
});
it("generates generate mode prompt by default", () => {
it("generates standalone mode prompt by default", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
@@ -290,6 +290,38 @@ describe("catalog.prompt", () => {
expect(prompt).not.toContain("conversationally");
});
it("accepts deprecated 'chat' as alias for 'inline'", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({}),
description: "",
slots: [],
},
},
actions: {},
});
const inlinePrompt = catalog.prompt({ mode: "inline" });
const chatPrompt = catalog.prompt({ mode: "chat" });
expect(chatPrompt).toEqual(inlinePrompt);
});
it("accepts deprecated 'generate' as alias for 'standalone'", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({}),
description: "",
slots: [],
},
},
actions: {},
});
const standalonePrompt = catalog.prompt({ mode: "standalone" });
const generatePrompt = catalog.prompt({ mode: "generate" });
expect(generatePrompt).toEqual(standalonePrompt);
});
it("uses actual catalog component names in examples", () => {
const catalog = defineCatalog(testSchema, {
components: {
+22 -6
View File
@@ -112,11 +112,14 @@ export interface PromptOptions {
/**
* Output mode for the generated prompt.
*
* - `"generate"` (default): The LLM should output only JSONL patches (no prose).
* - `"chat"`: The LLM should respond conversationally first, then output JSONL patches.
* - `"standalone"` (default): The LLM should output only JSONL patches (no prose).
* - `"inline"`: The LLM should respond conversationally first, then output JSONL patches.
* Includes rules about interleaving text with JSONL and not wrapping in code fences.
*
* @deprecated `"generate"` — use `"standalone"` instead.
* @deprecated `"chat"` — use `"inline"` instead.
*/
mode?: "generate" | "chat";
mode?: "standalone" | "inline" | "generate" | "chat";
}
/**
@@ -563,15 +566,28 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
const {
system = "You are a UI generator that outputs JSON.",
customRules = [],
mode = "generate",
mode: rawMode = "standalone",
} = options;
const mode: "standalone" | "inline" =
rawMode === "chat"
? (console.warn(
'[json-render] mode "chat" is deprecated, use "inline" instead',
),
"inline")
: rawMode === "generate"
? (console.warn(
'[json-render] mode "generate" is deprecated, use "standalone" instead',
),
"standalone")
: rawMode;
const lines: string[] = [];
lines.push(system);
lines.push("");
// Output format section - explain JSONL streaming patch format
if (mode === "chat") {
if (mode === "inline") {
lines.push("OUTPUT FORMAT (text + JSONL, RFC 6902 JSON Patch):");
lines.push(
"You respond conversationally. When generating UI, first write a brief explanation (1-3 sentences), then output JSONL patch lines wrapped in a ```spec code fence.",
@@ -1014,7 +1030,7 @@ Note: state patches appear right after the elements that use them, so the UI fil
// Rules
lines.push("RULES:");
const baseRules =
mode === "chat"
mode === "inline"
? [
"When generating UI, wrap all JSONL patches in a ```spec code fence - one JSON object per line inside the fence",
"Write a brief conversational response before any JSONL output",
+7
View File
@@ -1,5 +1,12 @@
# @json-render/image
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/image",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [
@@ -20,7 +20,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/image"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+7
View File
@@ -1,5 +1,12 @@
# @json-render/jotai
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/jotai",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore",
"keywords": [
@@ -14,7 +14,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/jotai"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+20
View File
@@ -1,5 +1,25 @@
# @json-render/mcp
## 0.12.1
### Patch Changes
- 54a1ecf: Rename generation modes and fix MCP React duplicate module error.
### Changed:
- **`@json-render/core`** — Renamed generation modes from `"generate"` / `"chat"` to `"standalone"` / `"inline"`. The old names still work but emit a deprecation warning.
### Fixed:
- **`@json-render/mcp`** — Resolved React duplicate module error (`useRef` returning null) by adding `resolve.dedupe` Vite configuration. Added `./build-app-html` export entry point.
### Other:
- Updated `homepage` URLs across all packages to point to `https://json-render.dev`.
- Reorganized skills directory structure for cleaner naming.
- Added skills documentation page to the web app.
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/mcp",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.",
"keywords": [
@@ -22,7 +22,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/mcp"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-email
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-email",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
"keywords": [
@@ -18,7 +18,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/react-email"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-native
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
@@ -19,7 +19,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/react-native"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-pdf
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-pdf",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
"keywords": [
@@ -18,7 +18,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/react-pdf"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+7
View File
@@ -1,5 +1,12 @@
# @internal/react-state
## 0.8.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.8.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@internal/react-state",
"version": "0.8.5",
"version": "0.8.6",
"private": true,
"license": "Apache-2.0",
"description": "Shared React state context for json-render renderer packages",
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
@@ -19,7 +19,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/react"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+7
View File
@@ -1,5 +1,12 @@
# @json-render/redux
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/redux",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "Redux adapter for json-render StateStore",
"keywords": [
@@ -14,7 +14,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/redux"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+7
View File
@@ -1,5 +1,12 @@
# @json-render/remotion
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
@@ -19,7 +19,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/remotion"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+8
View File
@@ -1,5 +1,13 @@
# @json-render/shadcn
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
## 0.12.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/shadcn",
"version": "0.12.0",
"version": "0.12.1",
"license": "Apache-2.0",
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
"keywords": [
@@ -22,7 +22,7 @@
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/shadcn"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
+243
View File
@@ -0,0 +1,243 @@
# @json-render/solid
SolidJS renderer for json-render. Turn JSON specs into Solid components with data binding, visibility, actions, validation, and streaming.
## Installation
```bash
npm install @json-render/core @json-render/solid zod
```
Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
## Quick Start
### 1. Create a Catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/solid/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
description: "A card container",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
description: "A clickable button",
},
Input: {
props: z.object({
value: z.union([z.string(), z.record(z.unknown())]).nullable(),
label: z.string(),
placeholder: z.string().nullable(),
}),
description: "Text input with optional state binding",
},
},
actions: {
submit: { description: "Submit the form" },
cancel: { description: "Cancel and close" },
},
});
```
### 2. Define Component Implementations
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions.
```tsx
import { defineRegistry, useBoundProp } from "@json-render/solid";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => (
<div class="card">
<h3>{renderProps.element.props.title as string}</h3>
{renderProps.children}
</div>
),
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
Input: (renderProps) => {
const [value, setValue] = useBoundProp(
renderProps.element.props.value,
renderProps.bindings?.value,
);
return (
<label>
{renderProps.element.props.label as string}
<input
value={String(value() ?? "")}
placeholder={String(renderProps.element.props.placeholder ?? "")}
onInput={(e) => setValue(e.currentTarget.value)}
/>
</label>
);
},
},
actions: {
submit: async () => {},
cancel: async () => {},
},
});
```
### 3. Render Specs
```tsx
import { Renderer, StateProvider, ActionProvider } from "@json-render/solid";
import { registry } from "./registry";
export function App(props: { spec: any }) {
return (
<StateProvider initialState={{ form: { name: "" } }}>
<ActionProvider handlers={{ submit: () => console.log("submit") }}>
<Renderer spec={props.spec} registry={registry} />
</ActionProvider>
</StateProvider>
);
}
```
## Spec Format
`@json-render/solid` uses the same flat element map format as React/Vue:
```typescript
interface Spec {
root: string;
elements: Record<string, UIElement>;
state?: Record<string, unknown>;
}
interface UIElement {
type: string;
props: Record<string, unknown>;
children?: string[];
visible?: VisibilityCondition;
watch?: Record<string, ActionBinding | ActionBinding[]>;
}
```
## Providers
| Provider | Purpose |
| --------------------- | ---------------------------------------------------- |
| `StateProvider` | State model and JSON Pointer read/write APIs |
| `ActionProvider` | Action dispatch, loading tracking, confirmation flow |
| `VisibilityProvider` | Visibility condition evaluation from current state |
| `ValidationProvider` | Field-level and full-form validation |
| `RepeatScopeProvider` | Repeat context (`$item`, `$index`, `$bindItem`) |
| `JSONUIProvider` | Combined provider wiring for renderer trees |
### External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or the component returned by `createRenderer`.
When `store` is provided, `initialState` and `onStateChange` are ignored.
```tsx
import { createStateStore, StateProvider } from "@json-render/solid";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>{/* ... */}</StateProvider>;
```
## Hooks
| Hook | Purpose |
| --------------------------------------------- | ----------------------------------------------------- |
| `useStateStore()` | Access `state`, `get`, `set`, `update`, `getSnapshot` |
| `useStateValue(path)` | Read a value by JSON Pointer path via accessor |
| `useStateBinding(path)` | Legacy two-way binding helper returning an accessor |
| `useVisibility()` / `useIsVisible()` | Visibility context and checks |
| `useActions()` / `useAction()` | Action context and single-action helper |
| `useValidation()` / `useOptionalValidation()` | Validation context (throwing/non-throwing) |
| `useFieldValidation(path, config)` | Field state accessors plus validate/touch/clear |
| `useBoundProp(value, binding)` | Fine-grained two-way binding helper |
| `useUIStream(options)` | Stream UI specs from an endpoint |
| `useChatUI(options)` | Chat-style spec generation hook |
## Built-in Actions
These actions are available in the Solid schema and handled by `ActionProvider`:
- `setState`
- `pushState`
- `removeState`
- `validateForm`
`setState`/`pushState`/`removeState` mutate the state model. `validateForm` validates registered fields and writes `{ valid, errors }` to state (`/formValidation` by default).
## Events and Action Binding
Components can use either `emit("event")` or `on("event")`.
- `emit` fires named event bindings directly.
- `on` returns an `EventHandle` with `emit`, `bound`, and `shouldPreventDefault`.
This mirrors the React package API while preserving Solid's fine-grained reactivity.
## Streaming
`useUIStream` and `useChatUI` support JSON patch streaming and mixed text/spec data parts.
```tsx
import { useUIStream } from "@json-render/solid";
const stream = useUIStream({ api: "/api/generate" });
await stream.send("Build me a dashboard");
```
## Key Exports
| Export | Purpose |
| ------------------ | -------------------------------------------------------------------------- |
| `defineRegistry` | Create catalog-aware component and action registry helpers |
| `Renderer` | Render a `Spec` with a component registry |
| `createRenderer` | Build an app-level renderer with provider wiring |
| `JSONUIProvider` | Combined provider tree (`state` + `visibility` + `validation` + `actions`) |
| `schema` | Solid element schema with built-in actions |
| `createStateStore` | Framework-agnostic in-memory `StateStore` |
### Types
| Export | Purpose |
| --------------------------- | -------------------------------------------------------- |
| `ComponentContext` | Catalog-aware component context type |
| `BaseComponentProps` | Catalog-agnostic component props type |
| `EventHandle` | Event metadata (`emit`, `bound`, `shouldPreventDefault`) |
| `StateStore` | Controlled state backend interface |
| `StateModel` | Renderer state model type |
| `SolidSchema` / `SolidSpec` | Solid schema/spec types |
## Differences from `@json-render/react`
Most APIs are intentionally aligned, but there are runtime behavior differences due to Solid:
- Solid components run once, then update via signals.
- Keep changing reads inside JSX expressions, `createMemo`, or `createEffect`.
- Avoid props destructuring in component signatures when values should remain reactive.
- Hooks that read changing state return accessors; call them inside JSX or effects.
## Documentation
Full docs: [json-render.dev/docs/api/solid](https://json-render.dev/docs/api/solid)
## License
Apache-2.0
+69
View File
@@ -0,0 +1,69 @@
{
"name": "@json-render/solid",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "SolidJS renderer for @json-render/core. JSON becomes Solid components.",
"keywords": [
"json",
"ui",
"solid",
"solidjs",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"components"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/solid"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.mjs",
"require": "./dist/schema.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"esbuild-plugin-solid": "^0.6.0",
"solid-js": "^1.9.0",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"solid-js": "^1.9.0",
"zod": "^4.0.0"
}
}
+154
View File
@@ -0,0 +1,154 @@
import type { JSX } from "solid-js";
import type {
Catalog,
InferCatalogComponents,
InferCatalogActions,
InferComponentProps,
InferActionParams,
StateModel,
} from "@json-render/core";
export type { StateModel };
// =============================================================================
// State Types
// =============================================================================
/**
* State setter function for updating application state
*/
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
// =============================================================================
// Component Types
// =============================================================================
/**
* Handle returned by the `on()` function for a specific event.
* Provides metadata about the event binding and a method to fire it.
*
* @example
* ```ts
* const press = on("press");
* if (press.shouldPreventDefault) e.preventDefault();
* press.emit();
* ```
*/
export interface EventHandle {
/** Fire the event (resolve action bindings) */
emit: () => void;
/** Whether any binding requested preventDefault */
shouldPreventDefault: boolean;
/** Whether any handler is bound to this event */
bound: boolean;
}
/**
* Catalog-agnostic base type for component render function arguments.
* Use this when building reusable component libraries that are not tied
* to a specific catalog.
*
* @example
* ```ts
* const Card = (ctx: BaseComponentProps<{ title?: string }>) => (
* <div>{ctx.props.title}{ctx.children}</div>
* );
* ```
*/
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
children?: JSX.Element;
/** Simple event emitter (shorthand). Fires the event and returns void. */
emit: (event: string) => void;
/** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */
on: (event: string) => EventHandle;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
loading?: boolean;
}
/**
* Context passed to component render functions
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => {
* return <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
* }
*/
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> extends BaseComponentProps<InferComponentProps<C, K>> {}
/**
* Component render function type for Solid
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => (
* <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
* );
*/
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => JSX.Element;
/**
* Registry of all component render functions for a catalog
* @example
* const components: Components<typeof myCatalog> = {
* Button: (ctx) => <button>{ctx.props.label}</button>,
* Input: (ctx) => <input placeholder={ctx.props.placeholder} />,
* };
*/
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
// =============================================================================
// Action Types
// =============================================================================
/**
* Action handler function type
* @example
* const viewCustomers: ActionFn<typeof catalog, 'viewCustomers'> = async (params, setState) => {
* const data = await fetch('/api/customers');
* setState(prev => ({ ...prev, customers: data }));
* };
*/
export type ActionFn<
C extends Catalog,
K extends keyof InferCatalogActions<C>,
> = (
params: InferActionParams<C, K> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
/**
* Registry of all action handlers for a catalog
* @example
* const actions: Actions<typeof myCatalog> = {
* viewCustomers: async (params, setState) => { ... },
* createCustomer: async (params, setState) => { ... },
* };
*/
export type Actions<C extends Catalog> = {
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
};
/**
* True when the catalog declares at least one action, false otherwise.
* Used by defineRegistry to conditionally require the `actions` field.
*/
export type CatalogHasActions<C extends Catalog> = [
InferCatalogActions<C>,
] extends [never]
? false
: [keyof InferCatalogActions<C>] extends [never]
? false
: true;
+193
View File
@@ -0,0 +1,193 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer";
import { useStateStore } from "./contexts/state";
// DO NOT destructure props — Solid requires proxy access
function Button(props: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => props.emit("press")}>
{props.element.props.label}
</button>
);
}
function Text(props: ComponentRenderProps<{ text: unknown }>) {
const display = () => {
const v = props.element.props.text;
return typeof v === "string" ? v : JSON.stringify(v);
};
return <span data-testid={`text-${props.element.type}`}>{display()}</span>;
}
let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
function StateProbe() {
const ctx = useStateStore();
probeGetSnapshot = ctx.getSnapshot;
return <div data-testid="state-probe" />;
}
const registry = {
Button,
Text,
};
function getProbeState(): Record<string, unknown> {
return probeGetSnapshot!();
}
describe("chained actions: live $state resolution (#141)", () => {
it("setState after pushState sees the post-push value via $state", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { items: ["initial"], observed: "not yet set" },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Add Item" },
on: {
press: [
{
action: "pushState",
params: { statePath: "/items", value: "new-item" },
},
{
action: "setState",
params: {
statePath: "/observed",
value: { $state: "/items" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.items).toEqual(["initial", "new-item"]);
expect(state.observed).toEqual(["initial", "new-item"]);
});
});
it("multiple pushState + setState chain resolves correctly", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { items: [], snapshot: null },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Go" },
on: {
press: [
{
action: "pushState",
params: { statePath: "/items", value: "a" },
},
{
action: "pushState",
params: { statePath: "/items", value: "b" },
},
{
action: "setState",
params: {
statePath: "/snapshot",
value: { $state: "/items" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.items).toEqual(["a", "b"]);
expect(state.snapshot).toEqual(["a", "b"]);
});
});
it("setState reading a path mutated by an earlier setState sees fresh value", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { counter: 0, counterCopy: -1 },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Go" },
on: {
press: [
{
action: "setState",
params: { statePath: "/counter", value: 42 },
},
{
action: "setState",
params: {
statePath: "/counterCopy",
value: { $state: "/counter" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.counter).toBe(42);
expect(state.counterCopy).toBe(42);
});
});
});
@@ -0,0 +1,346 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@solidjs/testing-library";
import type { JSX } from "solid-js";
import { StateProvider, useStateStore } from "./state";
import {
ValidationProvider,
useValidation,
useFieldValidation,
} from "./validation";
import { ActionProvider, useActions, useAction } from "./actions";
/**
* Build the full provider tree inline so that Solid's eager JSX evaluation
* always runs ActionProvider/ValidationProvider INSIDE StateProvider's scope.
*
* IMPORTANT: Do NOT pre-build JSX fragments as variables — Solid evaluates
* JSX eagerly, so `<ActionProvider>` would call `useStateStore()` before the
* StateProvider context is available.
*/
function withProviders<T>(
hook: () => T,
options: {
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
withValidation?: boolean;
} = {},
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as JSX.Element;
}
if (options.withValidation) {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ValidationProvider>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</ValidationProvider>
</StateProvider>
));
} else {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</StateProvider>
));
}
return result;
}
function withFullProviders(options: {
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
withValidation?: boolean;
}): {
stateCtx: ReturnType<typeof useStateStore>;
actionsCtx: ReturnType<typeof useActions>;
validationCtx?: ReturnType<typeof useValidation>;
} {
let stateCtx!: ReturnType<typeof useStateStore>;
let actionsCtx!: ReturnType<typeof useActions>;
let validationCtx: ReturnType<typeof useValidation> | undefined;
function TestComponent(): JSX.Element {
stateCtx = useStateStore();
actionsCtx = useActions();
if (options.withValidation) {
validationCtx = useValidation();
}
return (<div />) as JSX.Element;
}
if (options.withValidation) {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ValidationProvider>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</ValidationProvider>
</StateProvider>
));
} else {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</StateProvider>
));
}
return { stateCtx, actionsCtx, validationCtx };
}
describe("ActionProvider — provide/inject", () => {
it("useActions() throws outside a provider", () => {
expect(() => useActions()).toThrow(
"useActions must be used within an ActionProvider",
);
});
});
describe("ActionProvider — built-in setState", () => {
it("executes setState and updates state", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { count: 0 },
});
await actionsCtx.execute({
action: "setState",
params: { statePath: "/count", value: 5 },
});
expect(stateCtx.get("/count")).toBe(5);
});
});
describe("ActionProvider — built-in pushState", () => {
it("appends to existing array", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { items: ["a", "b"] },
});
await actionsCtx.execute({
action: "pushState",
params: { statePath: "/items", value: "c" },
});
expect(stateCtx.get("/items")).toEqual(["a", "b", "c"]);
});
it("creates array if path does not exist", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: {},
});
await actionsCtx.execute({
action: "pushState",
params: { statePath: "/newList", value: "first" },
});
expect(stateCtx.get("/newList")).toEqual(["first"]);
});
});
describe("ActionProvider — built-in removeState", () => {
it("removes item by index", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { items: ["a", "b", "c"] },
});
await actionsCtx.execute({
action: "removeState",
params: { statePath: "/items", index: 1 },
});
expect(stateCtx.get("/items")).toEqual(["a", "c"]);
});
});
describe("ActionProvider — built-in push/pop navigation", () => {
it("push sets /currentScreen and /navStack", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { currentScreen: "home" },
});
await actionsCtx.execute({
action: "push",
params: { screen: "settings" },
});
expect(stateCtx.get("/currentScreen")).toBe("settings");
expect(stateCtx.get("/navStack")).toEqual(["home"]);
});
it("pop restores previous screen from /navStack", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { currentScreen: "settings", navStack: ["home"] },
});
await actionsCtx.execute({ action: "pop" });
expect(stateCtx.get("/currentScreen")).toBe("home");
expect(stateCtx.get("/navStack")).toEqual([]);
});
});
describe("ActionProvider — custom handlers", () => {
it("executes custom handler with params", async () => {
const customHandler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({
handlers: { myAction: customHandler },
});
await actionsCtx.execute({
action: "myAction",
params: { foo: "bar" },
});
expect(customHandler).toHaveBeenCalledWith({ foo: "bar" });
});
it("console.warn for unknown action", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { actionsCtx } = withFullProviders({});
await actionsCtx.execute({ action: "unknownAction" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("unknownAction"),
);
warnSpy.mockRestore();
});
it("tracks loading state during async action execution", async () => {
let resolveHandler!: () => void;
const slowHandler = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveHandler = resolve;
}),
);
let executeFn!: ReturnType<typeof useActions>["execute"];
const { findByTestId } = render(() => {
function Inner(): JSX.Element {
const actions = useActions();
executeFn = actions.execute;
return (
<span data-testid="loading">
{actions.loadingActions.has("slowAction") ? "true" : "false"}
</span>
);
}
return (
<StateProvider initialState={{}}>
<ActionProvider handlers={{ slowAction: slowHandler }}>
<Inner />
</ActionProvider>
</StateProvider>
);
});
// Before execution — not loading
const el = await findByTestId("loading");
expect(el.textContent).toBe("false");
const executePromise = executeFn({ action: "slowAction" });
// During execution — loading (need to wait for Solid's reactivity to flush)
await vi.waitFor(() => {
expect(el.textContent).toBe("true");
});
resolveHandler();
await executePromise;
// After execution — no longer loading
await vi.waitFor(() => {
expect(el.textContent).toBe("false");
});
});
it("registerHandler allows dynamic handler registration", async () => {
const dynamicHandler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({});
actionsCtx.registerHandler("dynamicAction", dynamicHandler);
await actionsCtx.execute({
action: "dynamicAction",
params: { x: 1 },
});
expect(dynamicHandler).toHaveBeenCalledWith({ x: 1 });
});
it("handler receives resolved params object", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({
handlers: { myAction: handler },
});
await actionsCtx.execute({
action: "myAction",
params: { x: 1, y: "hello" },
});
expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
});
});
describe("ActionProvider — validateForm", () => {
it("writes { valid, errors } to state", async () => {
const { stateCtx, actionsCtx, validationCtx } = withFullProviders({
initialState: {},
withValidation: true,
});
validationCtx!.registerField("/form/email", {
checks: [{ type: "required", message: "Required" }],
});
await actionsCtx.execute({ action: "validateForm" });
expect(stateCtx.get("/formValidation")).toEqual({
valid: false,
errors: { "/form/email": ["Required"] },
});
});
it("warns without ValidationProvider", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { actionsCtx } = withFullProviders({
withValidation: false,
});
await actionsCtx.execute({ action: "validateForm" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("validateForm action was dispatched"),
);
warnSpy.mockRestore();
});
});
describe("useAction", () => {
it("returns { execute, isLoading: false } before execution", () => {
const result = withProviders(() => useAction({ action: "myAction" }), {
handlers: { myAction: vi.fn() },
});
expect(typeof result.execute).toBe("function");
expect(result.isLoading).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More