Compare commits

..
Author SHA1 Message Date
Chris Tate b28fbc3bb9 fixes 2026-02-18 13:10:14 -06:00
Chris Tate b6e4ca0584 fixes 2026-02-18 12:42:54 -06:00
Chris Tate d6207dc093 fixes actions 2026-02-18 12:39:49 -06:00
Chris Tate 1a36ccf7ae fixes 2026-02-18 12:29:13 -06:00
Chris Tate de81bf56ff fix data 2026-02-18 12:24:20 -06:00
Chris Tate 51d02822c5 progressive 2026-02-18 12:19:32 -06:00
Chris Tate 91ca8008dc fixes 2026-02-18 12:16:58 -06:00
Chris Tate c86e5455e3 refactor 2026-02-18 12:07:48 -06:00
Chris Tate ca68112032 stripe cleanup 2026-02-18 11:56:56 -06:00
Chris Tate 3a448f6366 fixes 2026-02-18 11:48:07 -06:00
Chris Tate c869713cad full screen flag 2026-02-18 11:42:34 -06:00
Chris Tate 7f4684c562 stripe cleanup 2026-02-18 11:32:13 -06:00
Chris Tate ba9ffa3c91 update star count (#120) 2026-02-18 09:21:04 -06:00
Chris Tate b7d5a75bfa update website docs (#119) 2026-02-17 02:26:57 -06:00
Chris Tate 0dfe07da45 v0.7.0 docs (#118) 2026-02-17 01:50:07 -06:00
github-actions[bot] c82eefd1c5 chore: version packages (#117)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-17 01:39:40 -06:00
Chris Tate 2d70fab00a v0.7.0 changeset (#116) 2026-02-17 01:37:35 -06:00
Chris Tate 320c935bfb shadcn (#115)
* shadcn

* fix build error

* fix readme

* fix CI error

* improvements

* fixes

* stronger types

* fix interleave

* fixes

* on arg

* minor fixes

* fix lock
2026-02-17 01:28:04 -06:00
github-actions[bot] e7103ce519 chore: version packages (#113)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-15 09:06:46 -06:00
Chris Tate 43ad534482 v0.6.1 changeset (#112) 2026-02-15 09:04:39 -06:00
Chris Tate ea97aff3e0 fix max update depth on homepage demo (#111)
When form inputs lack `$bindState` bindings (like in the homepage contact form simulation), `useFieldValidation` was called with `bindings?.value ?? ""` as the path. All three inputs registered at the same `""` path but with different validation configs. Each `registerField` call overwrote the previous one, triggering a re-render where the other inputs would see a mismatched config and re-register -- creating an infinite loop.

- Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at path `""` with different `checks` configs, causing them to overwrite each other endlessly
- Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update
2026-02-15 08:56:08 -06:00
149 changed files with 17298 additions and 675 deletions
+1
View File
@@ -6,6 +6,7 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
"@json-render/codegen"
-2
View File
@@ -23,8 +23,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.0.0
- name: Setup Node.js
uses: actions/setup-node@v4
+5
View File
@@ -44,3 +44,8 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
.env*.local
# Stripe apps (generated from template + build artifacts)
examples/stripe-app/*/stripe-app.json
examples/stripe-app/*/.build
examples/stripe-app/*/yarn.lock
+5 -1
View File
@@ -25,12 +25,16 @@ This ensures we don't install outdated versions that may have incompatible types
## Code Style
- Do not use emojis in code or UI
- Do not use barrel files (index.ts that re-exports from other files)
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
## Workflow
- Run `pnpm type-check` after each turn to ensure type safety
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
- Package `README.md` files in `packages/*/README.md`
- Root `README.md` (if packages table, install commands, or examples are affected)
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
- `AGENTS.md` (if workflow or conventions change)
<!-- opensrc:start -->
+36
View File
@@ -6,6 +6,8 @@ Generate dynamic, personalized UIs from prompts without sacrificing reliability.
```bash
npm install @json-render/core @json-render/react
# pre-built shadcn/ui components
npm install @json-render/shadcn
# or for mobile
npm install @json-render/core @json-render/react-native
# or for video
@@ -20,6 +22,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start
@@ -105,6 +108,7 @@ function Dashboard({ spec }) {
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@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 |
@@ -138,6 +142,38 @@ const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### shadcn/ui (Web)
```tsx
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
// Pick components from the 36 standard definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
},
actions: {},
});
// Use matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
},
});
<Renderer spec={spec} registry={registry} />
```
### React Native (Mobile)
```tsx
@@ -141,6 +141,25 @@ const catalog = defineCatalog(schema, {
});
```
### SchemaOptions
When creating schemas with `defineSchema`, you can pass options:
```typescript
interface SchemaOptions {
promptTemplate?: PromptTemplate; // Custom AI prompt generator
defaultRules?: string[]; // Default rules injected before custom rules in prompts
builtInActions?: BuiltInAction[]; // Actions always available at runtime, auto-injected into prompts
}
interface BuiltInAction {
name: string; // Action name (e.g. "setState")
description: string; // Human-readable description for the LLM
}
```
Built-in actions are injected into prompts as `[built-in]` and are handled by the runtime (e.g. `ActionProvider`) without requiring handlers in `defineRegistry`. The React schema declares `setState`, `pushState`, and `removeState` as built-in.
### defineSchema
Create custom schemas for different output formats (e.g., page-based, block-based).
@@ -333,6 +352,21 @@ const flat = nestedToFlat({
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
```
### createJsonRenderTransform
Low-level `TransformStream` that separates text from JSONL patches in a mixed AI stream. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text.
The transform properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
```typescript
import { createJsonRenderTransform } from '@json-render/core';
const transform = createJsonRenderTransform();
// Use with ReadableStream.pipeThrough(transform) for custom pipelines
```
Most users should use `pipeJsonRender()` instead, which wraps this transform for the common AI SDK use case.
### createMixedStreamParser
Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
@@ -556,6 +590,7 @@ interface ActionBinding {
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links)
}
```
+42 -2
View File
@@ -46,7 +46,9 @@ type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<b
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, and `loading` with catalog-inferred types.
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional.
```tsx
import { defineRegistry } from '@json-render/react';
@@ -87,10 +89,48 @@ type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit: (event: string) => void; // Emit a named event
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean;
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault`:
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
```
### BaseComponentProps
Catalog-agnostic base type for building reusable component libraries (e.g. `@json-render/shadcn`) that are not tied to a specific catalog:
```typescript
import type { BaseComponentProps } from "@json-render/react";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
<div>{props.title}{children}</div>
);
```
## Hooks
@@ -0,0 +1,175 @@
export const metadata = { title: "@json-render/shadcn API" }
# @json-render/shadcn
Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
## Installation
```bash
npm install @json-render/shadcn @json-render/core @json-render/react zod
```
Your app must have Tailwind CSS configured.
## Entry Points
| Entry Point | Exports | Use For |
|-------------|---------|---------|
| `@json-render/shadcn` | `shadcnComponents` | React implementations |
| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` | Catalog schemas (no React dependency, safe for server) |
## Usage
Pick the components you need from the standard definitions:
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
// Catalog: pick definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
// Registry: pick matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled by `ActionProvider` automatically. You don't need to declare them in your catalog.
## Extending with Custom Components
Add custom components alongside standard ones:
```typescript
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
// Standard
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Button: shadcnComponentDefinitions.Button,
// Custom
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).nullable(),
}),
description: "KPI metric display",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Button: shadcnComponents.Button,
Metric: ({ props }) => (
<div>
<span>{props.label}</span>
<span>{props.value}</span>
</div>
),
},
});
```
## Available Components
### Layout
| Component | Description |
|-----------|-------------|
| `Card` | Container card with optional title, description, maxWidth, centered |
| `Stack` | Flex container with direction, gap, align, justify |
| `Grid` | Grid layout with columns (1-6) and gap |
| `Separator` | Visual separator line with orientation |
### Navigation
| Component | Description |
|-----------|-------------|
| `Tabs` | Tabbed navigation with tabs array, defaultValue, value |
| `Accordion` | Collapsible sections with items array and type (single/multiple) |
| `Collapsible` | Single collapsible section with title and defaultOpen |
| `Pagination` | Page navigation with totalPages and page |
### Overlay
| Component | Description |
|-----------|-------------|
| `Dialog` | Modal dialog with title, description, openPath |
| `Drawer` | Bottom drawer with title, description, openPath |
| `Tooltip` | Hover tooltip with content and text |
| `Popover` | Click-triggered popover with trigger and content |
| `DropdownMenu` | Dropdown menu with label and items array |
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | Heading text with level (h1-h4) |
| `Text` | Paragraph with variant (body, caption, muted, lead, code) |
| `Image` | Image with alt, width, height |
| `Avatar` | User avatar with src, name, size |
| `Badge` | Status badge with text and variant |
| `Alert` | Alert banner with title, message, type |
| `Carousel` | Horizontally scrollable carousel with items |
| `Table` | Data table with columns and rows |
### Feedback
| Component | Description |
|-----------|-------------|
| `Progress` | Progress bar with value, max, label |
| `Skeleton` | Loading placeholder with width, height, rounded |
| `Spinner` | Loading spinner with size and label |
### Input
| Component | Description |
|-----------|-------------|
| `Button` | Clickable button with label, variant, disabled |
| `Link` | Anchor link with label and href |
| `Input` | Text input with label, name, type, placeholder, value, checks |
| `Textarea` | Multi-line text input with label, name, placeholder, rows, value, checks |
| `Select` | Dropdown select with label, name, options, value, checks |
| `Checkbox` | Checkbox with label, name, checked |
| `Radio` | Radio button group with label, name, options, value |
| `Switch` | Toggle switch with label, name, checked |
| `Slider` | Range slider with label, min, max, step, value |
| `Toggle` | Toggle button with label, pressed, variant |
| `ToggleGroup` | Group of toggle buttons with items, type, value |
| `ButtonGroup` | Group of buttons with buttons array and selected |
## Notes
- The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
- Components use Tailwind CSS classes -- your app must have Tailwind configured
- Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
- Form inputs support `checks` for validation (type + message pairs)
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`
@@ -4,6 +4,97 @@ export const metadata = { title: "Changelog" }
Notable changes and updates to json-render.
## v0.7.0
February 2026
### New: `@json-render/shadcn`
Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
```bash
npm install @json-render/shadcn
```
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
Components include: layout (Card, Stack, Grid, Separator), navigation (Tabs, Accordion, Collapsible, Pagination), overlay (Dialog, Drawer, Tooltip, Popover, DropdownMenu), content (Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table), feedback (Progress, Skeleton, Spinner), and input (Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup).
See the [API reference](/docs/api/shadcn) for full details.
### New: Event Handles (`on()`)
Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata:
- `emit()` -- fire the event
- `shouldPreventDefault` -- whether any action binding requested `preventDefault`
- `bound` -- whether any handler is bound to this event
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a href={props.href} onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}>{props.label}</a>
);
},
```
### New: `BaseComponentProps`
Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog.
```typescript
import type { BaseComponentProps } from "@json-render/react";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
<div>{props.title}{children}</div>
);
```
### New: Built-in Actions in Schema
Schemas can now declare `builtInActions` -- actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`.
### New: `preventDefault` on `ActionBinding`
Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented.
### Improved: Stream Transform Text Block Splitting
`createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`.
### Improved: `defineRegistry` Actions Requirement
`defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object.
---
## v0.6.0
February 2026
@@ -8,6 +8,14 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/react" />
## For React UI with shadcn/ui
Pre-built components for fast prototyping and production use:
<PackageInstall packages="@json-render/core @json-render/react @json-render/shadcn" />
Requires Tailwind CSS in your project. See the [@json-render/shadcn API reference](/docs/api/shadcn) for usage.
## For React Native
<PackageInstall packages="@json-render/core @json-render/react-native" />
@@ -163,9 +163,51 @@ export default function Page() {
}
```
## Quick Start with shadcn/ui
If you want to skip defining components from scratch, use `@json-render/shadcn` for 36 pre-built components:
```typescript
// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
export const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
```
```tsx
// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { shadcnComponents } from '@json-render/shadcn';
import { catalog } from './catalog';
export const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
## Next steps
- Learn about [catalogs](/docs/catalog) in depth
- Explore [data binding](/docs/data-binding) for dynamic values
- Add [action handlers](/docs/registry#action-handlers) for interactivity
- Implement [conditional visibility](/docs/visibility)
- Use [pre-built shadcn/ui components](/docs/api/shadcn) for fast prototyping
+27 -1
View File
@@ -69,14 +69,40 @@ Each component receives a `ComponentContext` object:
interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit: (event: string) => void; // Emit a named event (e.g. "press")
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the renderer is in a loading state
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata:
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
```
#### Using `bindings` for two-way binding
When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together:
+1 -1
View File
@@ -100,7 +100,7 @@ export function Header() {
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
<span>10.5k</span>
<span>11k</span>
</a>
<ThemeToggle />
</nav>
+1
View File
@@ -86,6 +86,7 @@ export const docsNavigation: NavSection[] = [
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
+12 -9
View File
@@ -781,9 +781,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
@@ -822,9 +823,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
@@ -864,9 +866,10 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
);
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
});
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
<div className="space-y-2">
+3 -1
View File
@@ -22,4 +22,6 @@ const nextConfig = {
const withMDX = createMDX({});
export default withMDX(nextConfig);
/** @type {import('next').NextConfig} */
const config = withMDX(nextConfig);
export default config;
+13 -101
View File
@@ -1,5 +1,6 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
// =============================================================================
@@ -45,48 +46,19 @@ const mesh3DProps = {
*/
export const explorerCatalog = defineCatalog(schema, {
components: {
// Layout
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
wrap: z.boolean().nullable(),
}),
slots: ["default"],
description: "Flex layout container",
example: { direction: "vertical", gap: "md", wrap: null },
},
Card: {
props: z.object({
title: z.string().nullable(),
description: z.string().nullable(),
}),
slots: ["default"],
description: "Card container with optional title and description",
example: { title: "Weather", description: "Current conditions" },
},
Grid: {
props: z.object({
columns: z.enum(["1", "2", "3", "4"]).nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
}),
slots: ["default"],
description: "Responsive grid layout container",
example: { columns: "3", gap: "md" },
},
// Typography
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
}),
description: "Section heading",
example: { text: "Data Explorer", level: "h1" },
},
// From @json-render/shadcn (used as-is)
Stack: shadcnComponentDefinitions.Stack,
Card: shadcnComponentDefinitions.Card,
Grid: shadcnComponentDefinitions.Grid,
Heading: shadcnComponentDefinitions.Heading,
Separator: shadcnComponentDefinitions.Separator,
Accordion: shadcnComponentDefinitions.Accordion,
Progress: shadcnComponentDefinitions.Progress,
Skeleton: shadcnComponentDefinitions.Skeleton,
Badge: shadcnComponentDefinitions.Badge,
Alert: shadcnComponentDefinitions.Alert,
// Chat-specific components (different schemas or fully custom)
Text: {
props: z.object({
content: z.string(),
@@ -96,32 +68,6 @@ export const explorerCatalog = defineCatalog(schema, {
example: { content: "Here is your data overview." },
},
// Data display
Badge: {
props: z.object({
text: z.string(),
variant: z
.enum(["default", "secondary", "destructive", "outline"])
.nullable(),
}),
description: "Status badge",
example: { text: "Live", variant: "default" },
},
Alert: {
props: z.object({
variant: z.enum(["default", "destructive"]).nullable(),
title: z.string(),
description: z.string().nullable(),
}),
description: "Alert or info message",
},
Separator: {
props: z.object({}),
description: "Visual divider",
},
Metric: {
props: z.object({
label: z.string(),
@@ -222,22 +168,6 @@ export const explorerCatalog = defineCatalog(schema, {
description: "Content for a specific tab",
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().nullable(),
}),
description: "Progress bar",
},
Skeleton: {
props: z.object({
width: z.string().nullable(),
height: z.string().nullable(),
}),
description: "Loading placeholder",
},
// Educational / Rich content
Callout: {
props: z.object({
@@ -254,24 +184,6 @@ export const explorerCatalog = defineCatalog(schema, {
},
},
Accordion: {
props: z.object({
items: z.array(
z.object({
title: z.string(),
content: z.string(),
}),
),
type: z.enum(["single", "multiple"]).nullable(),
}),
description:
"Collapsible accordion sections for organizing detailed content",
example: {
items: [{ title: "Overview", content: "A brief introduction." }],
type: "multiple",
},
},
Timeline: {
props: z.object({
items: z.array(
+13 -111
View File
@@ -2,6 +2,7 @@
import { useState, useRef, type ReactNode } from "react";
import { useBoundProp, defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import {
Bar,
BarChart as RechartsBarChart,
@@ -20,16 +21,6 @@ import {
type ChartConfig,
} from "@/components/ui/chart";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableHeader,
@@ -39,14 +30,6 @@ import {
TableCell,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Accordion as AccordionRoot,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
@@ -218,77 +201,25 @@ function AnimatedGroup({
export const { registry, handlers } = defineRegistry(explorerCatalog, {
components: {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${props.wrap ? "flex-wrap" : ""} ${gapClass}`}
>
{children}
</div>
);
},
Card: ({ props, children }) => (
<Card>
{(props.title || props.description) && (
<CardHeader>
{props.title && <CardTitle>{props.title}</CardTitle>}
{props.description && (
<CardDescription>{props.description}</CardDescription>
)}
</CardHeader>
)}
<CardContent className="flex flex-col gap-4">{children}</CardContent>
</Card>
),
Grid: ({ props, children }) => {
const colsClass =
{
"1": "grid-cols-1",
"2": "grid-cols-1 md:grid-cols-2",
"3": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
"4": "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
}[props.columns ?? "3"] ?? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return <div className={`grid ${colsClass} ${gapClass}`}>{children}</div>;
},
Heading: ({ props }) => {
const Tag = (props.level ?? "h2") as "h1" | "h2" | "h3" | "h4";
const sizeClass = {
h1: "text-3xl font-bold tracking-tight",
h2: "text-2xl font-semibold tracking-tight",
h3: "text-xl font-semibold",
h4: "text-lg font-medium",
}[props.level ?? "h2"];
return <Tag className={sizeClass}>{props.text}</Tag>;
},
// From @json-render/shadcn (used as-is)
Stack: shadcnComponents.Stack,
Card: shadcnComponents.Card,
Grid: shadcnComponents.Grid,
Heading: shadcnComponents.Heading,
Separator: shadcnComponents.Separator,
Accordion: shadcnComponents.Accordion,
Progress: shadcnComponents.Progress,
Skeleton: shadcnComponents.Skeleton,
Badge: shadcnComponents.Badge,
Alert: shadcnComponents.Alert,
// Chat-specific components
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Metric: ({ props }) => {
const TrendIcon =
props.trend === "up"
@@ -566,16 +497,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
<TabsContent value={props.value}>{children}</TabsContent>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Callout: ({ props }) => {
const config = {
info: {
@@ -626,23 +547,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
);
},
Accordion: ({ props }) => (
<AccordionRoot
type={props.type === "single" ? "single" : "multiple"}
collapsible={props.type === "single" ? true : undefined}
className="w-full"
>
{(props.items ?? []).map((item, i) => (
<AccordionItem key={i} value={`item-${i}`}>
<AccordionTrigger>{item.title}</AccordionTrigger>
<AccordionContent>
<p className="text-muted-foreground">{item.content}</p>
</AccordionContent>
</AccordionItem>
))}
</AccordionRoot>
),
Timeline: ({ props }) => (
<div className="relative pl-8">
{/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
@@ -987,8 +891,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
</DreiText>
),
},
actions: {},
});
// =============================================================================
+1
View File
@@ -15,6 +15,7 @@
"@ai-sdk/react": "^3.0.84",
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/shadcn": "workspace:*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@streamdown/code": "^1.0.2",
-7
View File
@@ -1,7 +0,0 @@
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
+32 -82
View File
@@ -1,99 +1,49 @@
# Stripe App Example
# Stripe App Examples
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
[Stripe Apps](https://stripe.com/docs/stripe-apps) examples demonstrating how to use json-render to build dynamic, AI-generated UI within the Stripe Dashboard.
## Overview
## Structure
This example shows how to integrate json-render with Stripe's UI Extension SDK to create dashboard views that can be dynamically generated from prompts. The app includes:
| Folder | Description |
|--------|-------------|
| [api/](./api) | Next.js server providing the `/api/generate` endpoint for AI-powered UI generation |
| [drawer-app/](./drawer-app) | Standard Stripe App that renders in the Dashboard drawer (sidebar) |
| [fullpage-app/](./fullpage-app) | Full-page Stripe App using `FullPageView` (requires alpha access) |
- **Home** - Revenue dashboard with AI-powered UI generation
- **Customers** - Customer list and management
- **Customer Details** - Individual customer view
- **Payments** - Payment list and details
- **Subscriptions** - Subscription management
- **Invoices** - Invoice list
- **Products** - Product catalog
## Quick Start
## Features
- Full Stripe UIXT component catalog mapped to json-render
- Dynamic spec generation from natural language prompts
- Real-time data binding with Stripe API
- Action handlers for Stripe operations (refunds, subscriptions, etc.)
## Getting Started
### Prerequisites
- [Stripe CLI](https://stripe.com/docs/stripe-cli) installed
- A Stripe account with app development enabled
### Installation
### 1. Start the API server
```bash
# Install dependencies
cd api
pnpm install
cp .env.example .env # Set AI_GATEWAY_API_KEY
pnpm dev
```
# Start the Stripe App in development mode
### 2. Start a Stripe App
For the standard drawer app:
```bash
cd drawer-app
pnpm install
pnpm setup
stripe apps start
```
### Development
The app uses the Stripe UI Extension SDK which handles authentication automatically - no API keys required in your environment.
For the full-page app (requires alpha access):
```bash
# Run linting
pnpm lint
# Run tests
pnpm test
cd fullpage-app
pnpm install
cp .env.example .env # Set STRIPE_APP_ID
pnpm setup
stripe apps start
# Navigate to https://dashboard.stripe.com/test/app/<your-app-id>
```
## How It Works
## Notes
1. **Component Catalog**: Maps json-render component types to Stripe UIXT components (`Box`, `Button`, `Badge`, etc.)
2. **Action Handlers**: Defines actions that can be triggered from the UI (e.g., `refundPayment`, `cancelSubscription`)
3. **StripeRenderer**: A custom renderer that uses the Stripe component catalog to render json-render specs
4. **Views**: Each view fetches data from the Stripe API and renders it using json-render specs
## Example Usage
```tsx
import { StripeRenderer, stripeCatalog } from "./lib/render";
import type { Spec } from "@json-render/react";
const spec: Spec = {
root: "container",
elements: {
container: {
type: "Stack",
props: { direction: "vertical", gap: "medium" },
children: ["heading", "metric"],
},
heading: {
type: "Heading",
props: { text: "Revenue", size: "large" },
children: [],
},
metric: {
type: "Metric",
props: { label: "Total", value: "$12,345", format: "currency" },
children: [],
},
},
};
function MyView() {
return <StripeRenderer spec={spec} catalog={stripeCatalog} />;
}
```
## Learn More
- [json-render Documentation](https://json-render.com/docs)
- [Stripe Apps Documentation](https://stripe.com/docs/stripe-apps)
- [Stripe UI Extension SDK](https://stripe.com/docs/stripe-apps/reference/ui-extension-sdk)
- The AI generation feature requires the API server to be running. Without it, the apps fall back to locally generated specs using real Stripe data.
- The `fullpage-app` uses Stripe's full-page apps **private developer preview**. You need Stripe to enable the feature flag for your app and account. See [fullpage-app/README.md](./fullpage-app/README.md) for details.
+3
View File
@@ -0,0 +1,3 @@
# AI Gateway API Key (required for AI generation)
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
+26
View File
@@ -0,0 +1,26 @@
# Stripe App API
A lightweight Next.js server that provides the `/api/generate` endpoint for AI-powered UI generation in the Stripe App examples.
## Setup
```bash
pnpm install
cp .env.example .env
# Set AI_GATEWAY_API_KEY
```
## Running
```bash
pnpm dev
```
Runs on port 3001. The Stripe apps (`drawer-app/` and `fullpage-app/`) expect this server to be running for AI generation features.
## API
### POST /api/generate
Accepts `{ prompt, systemPrompt }` and returns a streamed text response with a json-render spec.
@@ -0,0 +1,50 @@
import { streamText } from "ai";
import { gateway } from "@ai-sdk/gateway";
export const maxDuration = 60;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
export async function POST(req: Request) {
const { prompt, systemPrompt } = await req.json();
if (!prompt) {
return new Response(JSON.stringify({ error: "prompt is required" }), {
status: 400,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
try {
const result = streamText({
model: gateway(DEFAULT_MODEL),
system: systemPrompt ?? "You are a helpful UI builder.",
prompt,
temperature: 0.7,
});
const response = result.toTextStreamResponse();
for (const [key, value] of Object.entries(CORS_HEADERS)) {
response.headers.set(key, value);
}
return response;
} catch (err) {
const message = err instanceof Error ? err.message : "Generation failed";
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
}
export async function OPTIONS() {
return new Response(null, { headers: CORS_HEADERS });
}
+15
View File
@@ -0,0 +1,15 @@
export const metadata = {
title: "Stripe App API",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function Home() {
return (
<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<h1>Stripe App API</h1>
<p>POST /api/generate to generate UI specs.</p>
</main>
);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
+23
View File
@@ -0,0 +1,23 @@
{
"name": "stripe-app-api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.50",
"ai": "^6.0.91",
"next": "^16.1.6",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.1.0",
"typescript": "^5.4.5"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
@@ -0,0 +1,14 @@
# Stripe App ID (optional - overrides the template default)
# Get this from your Stripe Apps dashboard after uploading
# STRIPE_APP_ID=com.example.your-app-id
# Stripe App Name (optional - overrides the template default)
# STRIPE_APP_NAME=My App Name
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
+45
View File
@@ -0,0 +1,45 @@
# Stripe App (Drawer)
A [Stripe App](https://stripe.com/docs/stripe-apps) example demonstrating how to use json-render to build dynamic, AI-generated UI in the Stripe Dashboard drawer.
## Views
- **Home** - Revenue dashboard with AI-powered UI generation
- **Customers** - Customer list and management
- **Customer Details** - Individual customer view
- **Payments** - Payment list and details
- **Subscriptions** - Subscription management
- **Invoices** - Invoice list
- **Products** - Product catalog
## Setup
```bash
pnpm install
cp .env.example .env
# Optionally set STRIPE_APP_ID to your own app ID
pnpm setup
```
## Running
Start the API server first (from `../api`):
```bash
cd ../api && pnpm dev
```
Then start the Stripe app:
```bash
stripe apps start
```
## How It Works
1. **Component Catalog** - Maps json-render component types to Stripe UIXT components
2. **Action Handlers** - Stripe operations (refunds, subscriptions, etc.)
3. **StripeRenderer** - Custom renderer connecting json-render specs to Stripe components
4. **Views** - Each view fetches data from the Stripe API and renders it using json-render specs
@@ -9,12 +9,14 @@
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "^9.1.0",
"stripe": "^13.11.0"
"stripe": "^13.11.0",
"zod": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"scripts": {
"setup": "node scripts/setup.mjs",
"lint": "eslint src --max-warnings 0",
"test": "jest"
},
@@ -0,0 +1,42 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
function loadEnv() {
const envPath = resolve(root, ".env");
if (!existsSync(envPath)) return {};
const env = {};
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const val = trimmed.slice(idx + 1).trim();
env[key] = val;
}
return env;
}
const env = loadEnv();
const templatePath = resolve(root, "stripe-app.template.json");
const outputPath = resolve(root, "stripe-app.json");
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
if (env.STRIPE_APP_ID) {
template.id = env.STRIPE_APP_ID;
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
} else {
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
}
if (env.STRIPE_APP_NAME) {
template.name = env.STRIPE_APP_NAME;
}
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
console.log("Generated stripe-app.json");
@@ -0,0 +1 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,7 @@ type ExtendedRenderProps<P = Record<string, unknown>> =
ComponentRenderProps<P> & {
state?: Record<string, unknown>;
getValue?: (path: string) => unknown;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
};
// NOTE: All interactive components use `emit` to fire named events.
@@ -254,23 +255,47 @@ export const Metric: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
);
};
const BADGE_TYPES = new Set([
"neutral",
"urgent",
"warning",
"negative",
"positive",
"info",
]);
const BADGE_ALIAS: Record<string, string> = {
success: "positive",
error: "negative",
danger: "negative",
critical: "urgent",
default: "neutral",
primary: "info",
};
function coerceBadgeType(
raw: unknown,
): "neutral" | "urgent" | "warning" | "negative" | "positive" | "info" {
const s = String(raw ?? "neutral");
if (BADGE_TYPES.has(s))
return s as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info";
return (BADGE_ALIAS[s] ?? "neutral") as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info";
}
export const Badge: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
const { label, type = "neutral" } = element.props as Record<string, unknown>;
return (
<UIBadge
type={
type as
| "neutral"
| "urgent"
| "warning"
| "negative"
| "positive"
| "info"
}
>
{String(label || "")}
</UIBadge>
);
return <UIBadge type={coerceBadgeType(type)}>{String(label || "")}</UIBadge>;
};
export const Icon: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
@@ -309,6 +334,21 @@ export const Spinner: FunctionComponent<ExtendedRenderProps> = ({
// =========================================================================
// Feedback Components
// =========================================================================
const BANNER_TYPES = new Set(["default", "caution", "critical"]);
const BANNER_ALIAS: Record<string, string> = {
info: "default",
warning: "caution",
error: "critical",
danger: "critical",
success: "default",
};
function coerceBannerType(raw: unknown): "default" | "caution" | "critical" {
const s = String(raw ?? "default");
if (BANNER_TYPES.has(s)) return s as "default" | "caution" | "critical";
return (BANNER_ALIAS[s] ?? "default") as "default" | "caution" | "critical";
}
export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
const {
title,
@@ -319,7 +359,7 @@ export const Banner: FunctionComponent<ExtendedRenderProps> = ({ element }) => {
<UIBanner
title={title ? String(title) : undefined}
description={description ? String(description) : undefined}
type={type as "default" | "caution" | "critical"}
type={coerceBannerType(type)}
/>
);
};
@@ -634,11 +674,12 @@ export const DateField: FunctionComponent<ExtendedRenderProps> = ({
export const Button: FunctionComponent<ExtendedRenderProps> = ({
element,
emit,
onAction,
}) => {
const {
label,
action,
actionParams: _actionParams,
actionParams,
type = "primary",
size = "medium",
disabled,
@@ -646,6 +687,18 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
href,
} = element.props as Record<string, unknown>;
const handlePress = action
? () => {
emit("press");
if (onAction) {
onAction(
String(action),
actionParams as Record<string, unknown> | undefined,
);
}
}
: undefined;
return (
<UIButton
type={type as "primary" | "secondary" | "destructive"}
@@ -653,7 +706,7 @@ export const Button: FunctionComponent<ExtendedRenderProps> = ({
disabled={Boolean(disabled) || undefined}
pending={Boolean(pending) || undefined}
href={href ? String(href) : undefined}
onPress={action ? () => emit("press") : undefined}
onPress={handlePress}
>
{String(label || "")}
</UIButton>
@@ -715,14 +768,26 @@ export const BarChart: FunctionComponent<ExtendedRenderProps> = ({
x={String(xKey || "x")}
y={String(yKey || "y")}
color={colorKey ? String(colorKey) : undefined}
axis={showAxis as "x" | "y" | "both" | "none"}
grid={showGrid as "x" | "y" | "both" | "none"}
axis={coerceAxisGrid(showAxis, "both")}
grid={coerceAxisGrid(showGrid, "none")}
legend={Boolean(showLegend) || undefined}
tooltip={Boolean(showTooltip)}
/>
);
};
function coerceAxisGrid(
val: unknown,
fallback: string,
): "x" | "y" | "both" | "none" {
if (val === true) return "both";
if (val === false) return "none";
const s = String(val ?? fallback);
if (["x", "y", "both", "none"].includes(s))
return s as "x" | "y" | "both" | "none";
return fallback as "x" | "y" | "both" | "none";
}
export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
element,
}) => {
@@ -751,8 +816,8 @@ export const LineChart: FunctionComponent<ExtendedRenderProps> = ({
x={String(xKey || "x")}
y={String(yKey || "y")}
color={colorKey ? String(colorKey) : undefined}
axis={showAxis as "x" | "y" | "both" | "none"}
grid={showGrid as "x" | "y" | "both" | "none"}
axis={coerceAxisGrid(showAxis, "both")}
grid={coerceAxisGrid(showGrid, "none")}
legend={Boolean(showLegend) || undefined}
tooltip={Boolean(showTooltip)}
/>
@@ -9,6 +9,7 @@ import {
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
// =============================================================================
// Types
@@ -72,6 +73,12 @@ function buildRegistry(
}
return current;
},
onAction: (actionName: string, params?: Record<string, unknown>) => {
const setState = setDataRef.current;
if (setState) {
executeAction(actionName, params, setState, dataRef.current);
}
},
});
}
@@ -114,10 +121,15 @@ export function StripeRenderer({
[loading],
);
const mergedState = useMemo(
() => (spec?.state ? { ...data, ...spec.state } : data),
[data, spec?.state],
);
if (!spec) return null;
return (
<StateProvider initialState={data} onStateChange={onStateChange}>
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider>
<Renderer
@@ -0,0 +1,146 @@
import type { Spec } from "@json-render/react";
interface JsonPatch {
op: string;
path: string;
value?: unknown;
from?: string;
}
function setDeep(
obj: Record<string, unknown>,
segments: string[],
value: unknown,
): void {
let current: unknown = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
const next = (current as Record<string, unknown>)[seg];
if (next && typeof next === "object") {
current = next;
} else {
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
(current as Record<string, unknown>)[seg] = container;
current = container;
}
}
const last = segments[segments.length - 1];
if (Array.isArray(current)) {
(current as unknown[])[Number(last)] = value;
} else {
(current as Record<string, unknown>)[last] = value;
}
}
function setSpecValue(spec: Spec, path: string, value: unknown): void {
if (path === "/root") {
(spec as Record<string, unknown>).root = value as string;
return;
}
if (path === "/state") {
(spec as Record<string, unknown>).state = value;
return;
}
if (path.startsWith("/state/")) {
if (!spec.state) (spec as Record<string, unknown>).state = {};
const segments = path.slice("/state/".length).split("/");
setDeep(spec.state as Record<string, unknown>, segments, value);
return;
}
const elemMatch = path.match(/^\/elements\/(.+)/);
if (elemMatch) {
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
}
}
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const next: Spec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace":
setSpecValue(next, patch.path, patch.value);
break;
case "remove":
if (patch.path.startsWith("/elements/")) {
const key = patch.path.slice("/elements/".length);
delete next.elements[key];
}
break;
}
return next;
}
/**
* Fetch an AI generation endpoint that returns a JSONL patch stream,
* applying patches progressively via onPatch.
*/
export async function streamSpec(
url: string,
body: Record<string, unknown>,
onPatch: (spec: Spec) => void,
): Promise<Spec> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
let msg = `API error: ${response.status}`;
try {
const errData = await response.json();
if (errData.error) msg = errData.error;
} catch {
// use default
}
throw new Error(msg);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let spec: Spec = { root: "", elements: {} };
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) continue;
try {
const parsed = JSON.parse(trimmed) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip non-JSON lines
}
}
}
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip
}
}
return spec;
}
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -373,7 +374,7 @@ const CustomerDetails = ({ environment }: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -172,7 +173,7 @@ const Customers = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -1,10 +1,12 @@
import { useState, useCallback, useEffect } from "react";
import type { Spec } from "@json-render/react";
import {
Banner,
Box,
ContextView,
Divider,
Button,
Link,
TextField,
Tabs,
TabList,
@@ -20,6 +22,8 @@ import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
import { API_GENERATE_URL } from "../lib/config";
import { streamSpec } from "../lib/stream-spec";
// =============================================================================
// Dynamic Specs (use real data from context)
@@ -503,34 +507,23 @@ const Home = (_props: ExtensionContextValue) => {
setError(null);
try {
const response = await fetch("http://localhost:3000/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
const dataSnapshot = JSON.stringify(data, null, 2);
await streamSpec(
API_GENERATE_URL,
{
prompt: `${prompt}\n\nAVAILABLE STRIPE DATA (use this real data, do NOT invent fake numbers):\n${dataSnapshot}`,
systemPrompt: stripeCatalog.prompt({
system:
"You are a Stripe dashboard widget builder. Generate UI specs for displaying Stripe data.",
customRules: [
'LAYOUT CONSTRAINT: This UI renders inside a narrow Stripe dashboard drawer (~320px wide). NEVER place more than 2 small items side-by-side horizontally. Prefer vertical (stacked) layouts. Use direction:"horizontal" sparingly and only for very compact items like a label+value pair.',
'DATA: The user prompt includes real Stripe data under "AVAILABLE STRIPE DATA". You MUST use these real values when setting /state. NEVER invent or hallucinate numbers. If the data you need is not available, say so in a Text element rather than making up data. The data keys are: customers (data[], total), payments (data[], total, totalVolume, successRate), subscriptions (data[], total, active, trialing, pastDue, canceled), invoices (data[], total, totalAmount, paid, open, overdue).',
],
}),
}),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const responseData = await response.json();
if (responseData.spec) {
setCurrentSpec(responseData.spec);
return;
} else if (responseData.error) {
throw new Error(responseData.error);
}
}
throw new Error("Invalid response");
},
(spec) => setCurrentSpec(spec),
);
} catch (err) {
// Use dynamic specs with real data as fallback
const lowerPrompt = prompt.toLowerCase();
@@ -625,26 +618,47 @@ const Home = (_props: ExtensionContextValue) => {
{currentSpec && (
<>
<Divider />
<Box css={{ font: "subheading" }}>
Preview (with real Stripe data)
</Box>
<Box
css={{
padding: "medium",
borderRadius: "medium",
keyline: "neutral",
}}
>
<StripeRenderer
spec={currentSpec}
data={data}
setData={handleSetState}
loading={isGenerating}
/>
</Box>
<StripeRenderer
spec={currentSpec}
data={data}
setData={handleSetState}
loading={isGenerating}
/>
</>
)}
{(
data._actionResult as
| { message?: string; url?: string }
| undefined
)?.message && (
<Banner
title={(data._actionResult as { message: string }).message}
description={
(data._actionResult as { url?: string }).url
? "Open in Stripe Dashboard"
: undefined
}
actions={
(data._actionResult as { url?: string }).url ? (
<Link
href={(data._actionResult as { url: string }).url}
external
>
Open
</Link>
) : undefined
}
onDismiss={() =>
setState((prev) => {
const next = { ...prev };
delete next._actionResult;
return next;
})
}
/>
)}
<Divider />
<Box css={{ font: "caption", color: "secondary" }}>
Try: &quot;Show revenue metrics&quot; &bull; &quot;Show recent
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -215,7 +216,7 @@ const Invoices = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -264,7 +265,7 @@ const PaymentDetails = ({ environment }: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -181,7 +182,7 @@ const Payments = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -232,7 +233,7 @@ const Products = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),
@@ -10,6 +10,7 @@ import {
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import BrandIcon from "./brand_icon.svg";
import { API_GENERATE_URL } from "../lib/config";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
@@ -230,7 +231,7 @@ const Subscriptions = (_props: ExtensionContextValue) => {
const systemPrompt = stripeCatalog.prompt();
try {
const response = await fetch("/api/generate", {
const response = await fetch(API_GENERATE_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, systemPrompt }),

Before

Width:  |  Height:  |  Size: 881 B

After

Width:  |  Height:  |  Size: 881 B

@@ -37,8 +37,8 @@
"purpose": "Send and manage invoices"
},
{
"permission": "refund_write",
"purpose": "Process refunds"
"permission": "charge_write",
"purpose": "Process charges and refunds"
},
{
"permission": "product_read",
@@ -49,11 +49,11 @@
"purpose": "Create and manage products"
},
{
"permission": "price_read",
"permission": "plan_read",
"purpose": "Display pricing information"
},
{
"permission": "price_write",
"permission": "plan_write",
"purpose": "Create and manage prices"
},
{
@@ -0,0 +1,14 @@
# Stripe App ID (optional - overrides the template default)
# Get this from your Stripe Apps dashboard after uploading
# STRIPE_APP_ID=com.example.your-app-id
# Stripe App Name (optional - overrides the template default)
# STRIPE_APP_NAME=My App Name
# AI Gateway API Key
# Get your key from Vercel AI Gateway dashboard
AI_GATEWAY_API_KEY=
# Optional: Stripe API keys if you want to use real data
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_PUBLISHABLE_KEY=pk_test_...
@@ -0,0 +1,49 @@
# Full Page Stripe App (Alpha)
A full-page [Stripe App](https://stripe.com/docs/stripe-apps) example using json-render. This uses the `FullPageView` component and the `stripe.dashboard.fullpage` viewport, which are part of Stripe's **private developer preview**.
## Prerequisites
- [Stripe CLI](https://stripe.com/docs/stripe-cli) with the apps plugin
- A Stripe account with **full-page apps alpha access** enabled by Stripe
- Both your `app_id` and `account_id` must be flagged in by Stripe
## Setup
```bash
pnpm install
cp .env.example .env
# Set STRIPE_APP_ID to your app ID
pnpm setup
```
## Running
Start the API server first (from `../api`):
```bash
cd ../api && pnpm dev
```
Then start the Stripe app:
```bash
stripe apps start
```
Navigate directly to your full-page app URL (it will not appear in the left navigation):
```
https://dashboard.stripe.com/test/app/<your-app-id>
```
## Troubleshooting
If you are redirected to the Dashboard home page, the feature flag is not enabled for your account. Contact your Stripe partner to have both your `app_id` and `account_id` flagged in.
## Resources
- [FullPageView Component](https://docs.stripe.com/stripe-apps/components/fullpageview?app-sdk-version=9)
- [FullPageTabs Component](https://docs.stripe.com/stripe-apps/components/fullpagetabs?app-sdk-version=9)
@@ -0,0 +1,17 @@
import { config as reactConfig } from "@repo/eslint-config/react-internal";
/** @type {import("eslint").Linter.Config[]} */
export default [
...reactConfig,
{
rules: {
// Disable prop-types - we use TypeScript for type checking
"react/prop-types": "off",
// Allow underscore-prefixed unused variables
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
];
@@ -0,0 +1,7 @@
/* eslint-env node */
/* eslint-disable @typescript-eslint/no-var-requires */
const UIExtensionsConfig = require("@stripe/ui-extension-tools/jest.config.ui-extension");
module.exports = {
...UIExtensionsConfig,
};
@@ -0,0 +1,28 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.1",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
"type": "module",
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "9.2.0-alpha.0",
"stripe": "^13.11.0",
"zod": "^4.0.0"
},
"engines": {
"node": ">=14"
},
"scripts": {
"setup": "node scripts/setup.mjs",
"lint": "eslint src --max-warnings 0",
"test": "jest"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@stripe/ui-extension-tools": "^0.0.1",
"eslint": "^9.39.0"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
function loadEnv() {
const envPath = resolve(root, ".env");
if (!existsSync(envPath)) return {};
const env = {};
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const idx = trimmed.indexOf("=");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const val = trimmed.slice(idx + 1).trim();
env[key] = val;
}
return env;
}
const env = loadEnv();
const templatePath = resolve(root, "stripe-app.template.json");
const outputPath = resolve(root, "stripe-app.json");
const template = JSON.parse(readFileSync(templatePath, "utf-8"));
if (env.STRIPE_APP_ID) {
template.id = env.STRIPE_APP_ID;
console.log(`Using app ID from .env: ${env.STRIPE_APP_ID}`);
} else {
console.log(`No STRIPE_APP_ID in .env, using template default: ${template.id}`);
}
if (env.STRIPE_APP_NAME) {
template.name = env.STRIPE_APP_NAME;
}
writeFileSync(outputPath, JSON.stringify(template, null, 4) + "\n");
console.log("Generated stripe-app.json");
@@ -0,0 +1 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
File diff suppressed because it is too large Load Diff
@@ -1374,16 +1374,16 @@ export const actionHandlers: Record<
await actionHandlers.fetchCustomers({}, setState, {});
},
refreshPayments: async (_) => {
await actionHandlers.fetchPayments({}, setData, {});
refreshPayments: async (_, setState) => {
await actionHandlers.fetchPayments({}, setState, {});
},
refreshSubscriptions: async (_) => {
await actionHandlers.fetchSubscriptions({}, setData, {});
refreshSubscriptions: async (_, setState) => {
await actionHandlers.fetchSubscriptions({}, setState, {});
},
refreshInvoices: async (_) => {
await actionHandlers.fetchInvoices({}, setData, {});
refreshInvoices: async (_, setState) => {
await actionHandlers.fetchInvoices({}, setState, {});
},
exportData: async (params, _, data) => {
@@ -1431,11 +1431,8 @@ export const actionHandlers: Record<
// ===========================================================================
// Navigation Actions
// ===========================================================================
navigate: async (params) => {
if (params?.statePath) {
console.log("Navigate to:", params.statePath);
// In a real app, this would use the router
}
navigate: async (_params) => {
// In a real app, this would use the router
},
openDashboard: async (params) => {
@@ -1463,8 +1460,7 @@ export const actionHandlers: Record<
// ===========================================================================
// Form Actions
// ===========================================================================
submitForm: async (params, _, data) => {
console.log("Submit form:", params?.formId, data);
submitForm: async (_params, _, _data) => {
// Implementation depends on form handling logic
},
@@ -1475,9 +1471,8 @@ export const actionHandlers: Record<
}
},
validateForm: async (params, _, data) => {
console.log("Validate form:", params?.formId, data);
return;
validateForm: async (_params, _, _data) => {
// Implementation depends on form validation logic
},
setFormValue: async (params, setState) => {
@@ -1492,9 +1487,8 @@ export const actionHandlers: Record<
// ===========================================================================
// UI Actions
// ===========================================================================
showToast: async (params) => {
// In UIXT, you would use the showToast API from the SDK
console.log("Toast:", params?.type, params?.message);
showToast: async (_params) => {
// In a real app, use showToast from @stripe/ui-extension-sdk/utils
},
copyToClipboard: async (params) => {
@@ -1567,6 +1561,6 @@ export async function executeAction(
if (handler) {
await handler(params, setState, data);
} else {
console.log("Unknown action:", actionName, params);
console.warn("Unknown action:", actionName);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
// Catalog
export { stripeCatalog } from "./catalog";
// Components
export { components, Fallback } from "./catalog/components";
// Actions
export { actionHandlers, executeAction } from "./catalog/actions";
// Renderer
export { StripeRenderer, type StripeRendererProps } from "./renderer";
@@ -0,0 +1,145 @@
import { useMemo, useRef, type ReactNode } from "react";
import {
Renderer,
type ComponentRegistry,
type Spec,
StateProvider,
VisibilityProvider,
ActionProvider,
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
// =============================================================================
// Types
// =============================================================================
type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
export interface StripeRendererProps {
/** The UI spec to render (from json-render) */
spec: Spec | null;
/** Data context for components */
data?: Record<string, unknown>;
/** Function to update data */
setData?: SetState;
/** Callback when data changes */
onStateChange?: (path: string, value: unknown) => void;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
}
// =============================================================================
// Build Registry
// =============================================================================
/**
* Build a component registry from our components map.
* Uses refs to avoid recreating on data changes.
*/
function buildRegistry(
dataRef: React.RefObject<Record<string, unknown>>,
setDataRef: React.RefObject<SetState | undefined>,
loading?: boolean,
): ComponentRegistry {
const registry: ComponentRegistry = {};
for (const [name, Component] of Object.entries(components)) {
const noop = () => {};
registry[name] = (renderProps: {
element: { type: string; props: Record<string, unknown> };
children?: ReactNode;
emit?: (event: string) => void;
}) =>
Component({
element: renderProps.element,
children: renderProps.children,
emit: renderProps.emit ?? noop,
loading,
state: dataRef.current,
getValue: (path: string) => {
const data = dataRef.current;
const parts = path.replace(/^\//, "").split("/");
let current: unknown = data;
for (const part of parts) {
if (current && typeof current === "object" && part in current) {
current = (current as Record<string, unknown>)[part];
} else {
return undefined;
}
}
return current;
},
onAction: (actionName: string, params?: Record<string, unknown>) => {
const setState = setDataRef.current;
if (setState) {
executeAction(actionName, params, setState, dataRef.current);
}
},
});
}
return registry;
}
/**
* Fallback component for unknown types
*/
const fallbackRegistry = (renderProps: {
element: { type: string; props: Record<string, unknown> };
}) => <Fallback element={renderProps.element} />;
// =============================================================================
// StripeRenderer Component
// =============================================================================
/**
* Main renderer component for Stripe UIXT.
*
* Wraps the json-render Renderer with all necessary providers
* and connects it to the Stripe component implementations.
*/
export function StripeRenderer({
spec,
data = {},
setData,
onStateChange,
loading,
}: StripeRendererProps): ReactNode {
// Use refs to keep registry stable while still accessing latest data/setData
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
// Memoize registry - only changes when loading changes
const registry = useMemo(
() => buildRegistry(dataRef, setDataRef, loading),
[loading],
);
const mergedState = useMemo(
() => (spec?.state ? { ...data, ...spec.state } : data),
[data, spec?.state],
);
if (!spec) return null;
return (
<StateProvider initialState={mergedState} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRegistry}
loading={loading}
/>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
@@ -0,0 +1,146 @@
import type { Spec } from "@json-render/react";
interface JsonPatch {
op: string;
path: string;
value?: unknown;
from?: string;
}
function setDeep(
obj: Record<string, unknown>,
segments: string[],
value: unknown,
): void {
let current: unknown = obj;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
const next = (current as Record<string, unknown>)[seg];
if (next && typeof next === "object") {
current = next;
} else {
const container = /^\d+$/.test(segments[i + 1]) ? [] : {};
(current as Record<string, unknown>)[seg] = container;
current = container;
}
}
const last = segments[segments.length - 1];
if (Array.isArray(current)) {
(current as unknown[])[Number(last)] = value;
} else {
(current as Record<string, unknown>)[last] = value;
}
}
function setSpecValue(spec: Spec, path: string, value: unknown): void {
if (path === "/root") {
(spec as Record<string, unknown>).root = value as string;
return;
}
if (path === "/state") {
(spec as Record<string, unknown>).state = value;
return;
}
if (path.startsWith("/state/")) {
if (!spec.state) (spec as Record<string, unknown>).state = {};
const segments = path.slice("/state/".length).split("/");
setDeep(spec.state as Record<string, unknown>, segments, value);
return;
}
const elemMatch = path.match(/^\/elements\/(.+)/);
if (elemMatch) {
spec.elements[elemMatch[1]] = value as Spec["elements"][string];
}
}
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const next: Spec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace":
setSpecValue(next, patch.path, patch.value);
break;
case "remove":
if (patch.path.startsWith("/elements/")) {
const key = patch.path.slice("/elements/".length);
delete next.elements[key];
}
break;
}
return next;
}
/**
* Fetch an AI generation endpoint that returns a JSONL patch stream,
* applying patches progressively via onPatch.
*/
export async function streamSpec(
url: string,
body: Record<string, unknown>,
onPatch: (spec: Spec) => void,
): Promise<Spec> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
let msg = `API error: ${response.status}`;
try {
const errData = await response.json();
if (errData.error) msg = errData.error;
} catch {
// use default
}
throw new Error(msg);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let spec: Spec = { root: "", elements: {} };
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) continue;
try {
const parsed = JSON.parse(trimmed) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip non-JSON lines
}
}
}
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer.trim()) as JsonPatch;
if (parsed.op) {
spec = applyPatch(spec, parsed);
onPatch(spec);
}
} catch {
// skip
}
}
return spec;
}
@@ -0,0 +1,37 @@
import Stripe from "stripe";
import {
createHttpClient,
STRIPE_API_KEY,
} from "@stripe/ui-extension-sdk/http_client";
/**
* Create a Stripe client for use in Stripe Apps.
*
* The Stripe UI Extension SDK handles authentication automatically
* through the httpClient - no real API key is needed.
*/
export const stripe = new Stripe(STRIPE_API_KEY, {
httpClient: createHttpClient(),
apiVersion: "2024-12-18.acacia",
});
/**
* Format amount for display (converts cents to dollars)
*/
export function formatAmount(amount: number, currency = "usd"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
}
/**
* Format date for display
*/
export function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
@@ -0,0 +1,308 @@
import { useState, useCallback, useEffect } from "react";
import type { Spec } from "@json-render/react";
import {
Box,
FullPageView,
FullPageTabs,
FullPageTab,
Button,
TextField,
Divider,
List,
ListItem,
Spinner,
} from "@stripe/ui-extension-sdk/ui";
import type { ExtensionContextValue } from "@stripe/ui-extension-sdk/context";
import { stripeCatalog, StripeRenderer } from "../lib/render";
import { executeAction } from "../lib/render/catalog/actions";
import { API_GENERATE_URL } from "../lib/config";
import { streamSpec } from "../lib/stream-spec";
function createOverviewSpec(data: Record<string, unknown>): Spec {
const customers = data.customers as { total?: number } | undefined;
const payments = data.payments as
| { totalVolume?: string; successRate?: string }
| undefined;
const subscriptions = data.subscriptions as
| { active?: number; trialing?: number; pastDue?: number }
| undefined;
return {
root: "root",
elements: {
root: {
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["metrics", "divider", "refresh"],
},
metrics: {
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["m1", "m2", "m3"],
},
m1: {
type: "Metric",
props: {
label: "Payment Volume",
value: payments?.totalVolume ?? "$0",
change: payments?.successRate
? `${payments.successRate} success`
: null,
changeType: "positive",
},
children: [],
},
m2: {
type: "Metric",
props: {
label: "Active Subscriptions",
value: String(subscriptions?.active ?? 0),
change: subscriptions?.trialing
? `+${subscriptions.trialing} trialing`
: null,
changeType: "positive",
},
children: [],
},
m3: {
type: "Metric",
props: {
label: "Total Customers",
value: String(customers?.total ?? 0),
changeType: "neutral",
},
children: [],
},
divider: { type: "Divider", props: {}, children: [] },
refresh: {
type: "Button",
props: {
label: "Refresh Data",
action: "refreshData",
type: "secondary",
},
children: [],
},
},
};
}
const componentCatalogInfo = Object.entries(stripeCatalog.data.components).map(
([name, def]) => ({
name,
description: def.description,
}),
);
const actionsCatalogInfo = Object.entries(stripeCatalog.data.actions).map(
([name, def]) => ({
name,
description: def.description,
}),
);
const FullPage = (_props: ExtensionContextValue) => {
const [prompt, setPrompt] = useState("");
const [isGenerating, setIsGenerating] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [currentSpec, setCurrentSpec] = useState<Spec | null>(null);
const [data, setState] = useState<Record<string, unknown>>({});
const [error, setError] = useState<string | null>(null);
const handleSetState = useCallback(
(updater: (prev: Record<string, unknown>) => Record<string, unknown>) => {
setState((prev) => updater(prev));
},
[],
);
useEffect(() => {
async function loadData() {
setIsLoading(true);
try {
await executeAction("refreshData", {}, handleSetState, {});
} catch (err) {
console.error("Failed to load data:", err);
setError("Failed to load Stripe data");
} finally {
setIsLoading(false);
}
}
loadData();
}, [handleSetState]);
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
setError(null);
try {
await streamSpec(
API_GENERATE_URL,
{
prompt,
systemPrompt: stripeCatalog.prompt({
system:
"You are a Stripe dashboard builder. Generate UI specs for displaying Stripe data in a full-page layout.",
}),
},
(spec) => setCurrentSpec(spec),
);
} catch (err) {
setCurrentSpec(createOverviewSpec(data));
setError(
`Using local generation (${err instanceof Error ? err.message : "API unavailable"})`,
);
} finally {
setIsGenerating(false);
}
};
if (isLoading) {
return (
<FullPageView>
<FullPageTabs>
<FullPageTab
id="overview"
label="Overview"
content={
<Box
css={{
stack: "y",
gap: "medium",
alignX: "center",
paddingY: "xlarge",
}}
>
<Spinner size="large" />
<Box css={{ font: "body", color: "secondary" }}>
Loading Stripe data...
</Box>
</Box>
}
/>
</FullPageTabs>
</FullPageView>
);
}
return (
<FullPageView
pageAction={{
label: "Refresh Data",
onPress: () => executeAction("refreshData", {}, handleSetState, {}),
}}
>
<FullPageTabs>
<FullPageTab
id="overview"
label="Overview"
content={
<Box css={{ stack: "y", gap: "large", padding: "large" }}>
<StripeRenderer
spec={createOverviewSpec(data)}
data={data}
setData={handleSetState}
loading={false}
/>
</Box>
}
/>
<FullPageTab
id="generate"
label="Generate"
content={
<Box css={{ stack: "y", gap: "medium", padding: "large" }}>
<TextField
label="Describe the dashboard you want"
placeholder="e.g., Show MRR, payments, or subscriptions"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
<Button
type="primary"
onPress={handleGenerate}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? "Generating..." : "Generate"}
</Button>
{error && (
<Box css={{ color: "secondary", font: "caption" }}>{error}</Box>
)}
{currentSpec && (
<>
<Divider />
<Box css={{ font: "subheading" }}>
Preview (with real Stripe data)
</Box>
<Box
css={{
padding: "medium",
borderRadius: "medium",
keyline: "neutral",
}}
>
<StripeRenderer
spec={currentSpec}
data={data}
setData={handleSetState}
loading={isGenerating}
/>
</Box>
</>
)}
</Box>
}
/>
<FullPageTab
id="components"
label="Components"
content={
<Box css={{ padding: "large" }}>
<Box css={{ font: "subheading", marginBottom: "medium" }}>
Available Components ({componentCatalogInfo.length})
</Box>
<List>
{componentCatalogInfo.map((comp) => (
<ListItem
key={comp.name}
title={comp.name}
secondaryTitle={comp.description}
/>
))}
</List>
</Box>
}
/>
<FullPageTab
id="actions"
label="Actions"
content={
<Box css={{ padding: "large" }}>
<Box css={{ font: "subheading", marginBottom: "medium" }}>
Available Actions ({actionsCatalogInfo.length})
</Box>
<List>
{actionsCatalogInfo.map((action) => (
<ListItem
key={action.name}
title={action.name}
secondaryTitle={action.description}
/>
))}
</List>
</Box>
}
/>
</FullPageTabs>
</FullPageView>
);
};
export default FullPage;
@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.218 9.10285C24.4008 8.99422 24.5577 8.84887 24.682 8.67797L17.5155 4.41809C16.5777 3.86064 15.4223 3.86064 14.4845 4.41809L7.31798 8.67797C7.44229 8.84887 7.59924 8.99422 7.78199 9.10285L15.2509 13.5425C15.7163 13.8192 16.2837 13.8192 16.7491 13.5425L24.218 9.10285Z" fill="#FFFFFF"/>
<path d="M26.5 11.5603C26.5 11.4215 26.491 11.2841 26.4736 11.1486C26.2529 11.3479 26.0113 11.5268 25.7509 11.6817L18.282 16.1213C16.8718 16.9596 15.1282 16.9596 13.718 16.1213L6.2491 11.6817C5.98866 11.5268 5.7471 11.3479 5.52644 11.1486C5.50895 11.2841 5.5 11.4215 5.5 11.5603V20.4397C5.5 21.5546 6.07772 22.5848 7.01554 23.1423L14.4845 27.5819C15.4223 28.1394 16.5777 28.1394 17.5155 27.5819L24.9845 23.1423C25.9223 22.5848 26.5 21.5546 26.5 20.4397V11.5603Z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 881 B

@@ -0,0 +1,97 @@
{
"id": "com.example.json-render-demo",
"version": "0.0.1",
"name": "json-render Full Page Demo",
"icon": "",
"permissions": [
{
"permission": "customer_read",
"purpose": "Display customer information in dashboards"
},
{
"permission": "customer_write",
"purpose": "Create and manage customers"
},
{
"permission": "payment_intent_read",
"purpose": "Display payment information in dashboards"
},
{
"permission": "charge_read",
"purpose": "Display charge information and details"
},
{
"permission": "subscription_read",
"purpose": "Display subscription information in dashboards"
},
{
"permission": "subscription_write",
"purpose": "Manage subscriptions"
},
{
"permission": "invoice_read",
"purpose": "Display invoice information in dashboards"
},
{
"permission": "invoice_write",
"purpose": "Send and manage invoices"
},
{
"permission": "charge_write",
"purpose": "Process charges and refunds"
},
{
"permission": "product_read",
"purpose": "Display product catalog"
},
{
"permission": "product_write",
"purpose": "Create and manage products"
},
{
"permission": "plan_read",
"purpose": "Display pricing information"
},
{
"permission": "plan_write",
"purpose": "Create and manage prices"
},
{
"permission": "balance_read",
"purpose": "Display account balance"
},
{
"permission": "payout_read",
"purpose": "Display payout information"
},
{
"permission": "event_read",
"purpose": "Display webhook events"
},
{
"permission": "dispute_read",
"purpose": "Display dispute information"
},
{
"permission": "coupon_read",
"purpose": "Display coupon information"
},
{
"permission": "coupon_write",
"purpose": "Create and manage coupons"
}
],
"ui_extension": {
"views": [
{
"viewport": "stripe.dashboard.fullpage",
"component": "FullPage"
}
],
"content_security_policy": {
"connect-src": null,
"image-src": null,
"purpose": "No external connections needed for core functionality"
}
}
}
@@ -0,0 +1,3 @@
{
"extends": "@stripe/ui-extension-tools/tsconfig.ui-extension"
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="@stripe/ui-extension-tools" />
+1 -1
View File
@@ -40,7 +40,7 @@
"typescript": "5.9.2",
"vitest": "^4.0.17"
},
"packageManager": "pnpm@9.0.0",
"packageManager": "pnpm@10.29.3",
"engines": {
"node": ">=18"
},
+20
View File
@@ -1,5 +1,25 @@
# @json-render/codegen
## 0.7.0
### Patch Changes
- Updated dependencies [2d70fab]
- @json-render/core@0.7.0
## 0.6.1
### Patch Changes
- @json-render/core@0.6.1
## 0.6.1
### Patch Changes
- Updated dependencies [ea97aff]
- @json-render/core@0.6.1
## 0.6.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.6.0",
"version": "0.7.0",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+53
View File
@@ -1,5 +1,58 @@
# @json-render/core
## 0.7.0
### Minor Changes
- 2d70fab: New `@json-render/shadcn` package, event handles, built-in actions, and stream improvements.
### New: `@json-render/shadcn` Package
Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 30+ components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
- `shadcnComponentDefinitions` — Zod-based catalog definitions for all components (server-safe, no React dependency via `@json-render/shadcn/catalog`)
- `shadcnComponents` — React implementations for all components
- Layout: Card, Stack, Grid, Separator
- Navigation: Tabs, Accordion, Collapsible, Pagination
- Overlay: Dialog, Drawer, Tooltip, Popover, DropdownMenu
- Content: Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table
- Feedback: Progress, Skeleton, Spinner
- Input: Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup
### New: Event Handles (`on()`)
Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata:
- `emit()` — fire the event
- `shouldPreventDefault` — whether any action binding requested `preventDefault`
- `bound` — whether any handler is bound to this event
### New: `BaseComponentProps`
Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog.
### New: Built-in Actions in Schema
Schemas can now declare `builtInActions` — actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`.
### New: `preventDefault` on `ActionBinding`
Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented.
### Improved: Stream Transform Text Block Splitting
`createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`.
### Improved: `defineRegistry` Actions Requirement
`defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object.
## 0.6.1
## 0.6.1
### Patch Changes
- ea97aff: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update.
## 0.6.0
### Minor Changes
+26
View File
@@ -157,6 +157,14 @@ const spec = compileSpecStream<MySpec>(jsonlString);
| `defineSchema(builder, options?)` | Create a schema with spec/catalog structure |
| `SchemaBuilder` | Builder with `s.object()`, `s.array()`, `s.map()`, etc. |
Schema options:
| Option | Purpose |
|--------|---------|
| `promptTemplate` | Custom AI prompt generator |
| `defaultRules` | Default rules injected before custom rules in prompts |
| `builtInActions` | Actions always available at runtime, auto-injected into prompts (e.g. `setState`) |
### Catalog
| Export | Purpose |
@@ -196,12 +204,30 @@ const spec = compileSpecStream<MySpec>(jsonlString);
| `autoFixSpec(spec)` | Auto-fix common spec issues (returns corrected copy) |
| `formatSpecIssues(issues)` | Format validation issues as readable strings |
### Actions
| Export | Purpose |
|--------|---------|
| `ActionBinding` | Action binding with `action`, `params`, `confirm`, `preventDefault`, etc. |
| `BuiltInAction` | Built-in action definition with `name` and `description` |
### Chat Mode (Mixed Streams)
| Export | Purpose |
|--------|---------|
| `createJsonRenderTransform()` | TransformStream that separates text from JSONL patches in a mixed stream |
| `pipeJsonRender()` | Server-side helper to pipe a mixed stream through the transform |
| `SPEC_DATA_PART` / `SPEC_DATA_PART_TYPE` | Constants for filtering spec data parts |
The transform splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
### Types
| Export | Purpose |
|--------|---------|
| `Spec` | Base spec type |
| `Catalog` | Catalog type |
| `BuiltInAction` | Built-in action type (`name` + `description`) |
| `VisibilityCondition` | Visibility condition type (used by `$cond`) |
| `VisibilityContext` | Context for evaluating visibility and prop expressions |
| `SpecStreamLine` | Single patch operation |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.6.0",
"version": "0.7.0",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
+3
View File
@@ -47,6 +47,8 @@ export interface ActionBinding {
onSuccess?: ActionOnSuccess;
/** Handler after failed execution */
onError?: ActionOnError;
/** Whether to prevent default browser behavior (e.g. navigation on links) */
preventDefault?: boolean;
}
/**
@@ -91,6 +93,7 @@ export const ActionBindingSchema = z.object({
confirm: ActionConfirmSchema.optional(),
onSuccess: ActionOnSuccessSchema.optional(),
onError: ActionOnErrorSchema.optional(),
preventDefault: z.boolean().optional(),
});
/**
+1
View File
@@ -143,6 +143,7 @@ export type {
Schema,
PromptTemplate,
SchemaOptions,
BuiltInAction,
} from "./schema";
export { defineSchema } from "./schema";
+39 -3
View File
@@ -66,6 +66,8 @@ export interface Schema<TDef extends SchemaDefinition = SchemaDefinition> {
readonly promptTemplate?: PromptTemplate;
/** Default rules baked into the schema (injected before customRules) */
readonly defaultRules?: string[];
/** Built-in actions always available at runtime (injected into prompts automatically) */
readonly builtInActions?: BuiltInAction[];
/** Create a catalog from this schema */
createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
catalog: TCatalog,
@@ -140,6 +142,18 @@ export type PromptTemplate<TCatalog = unknown> = (
context: PromptContext<TCatalog>,
) => string;
/**
* A built-in action that is always available regardless of catalog configuration.
* These are handled by the runtime (e.g. ActionProvider) and injected into prompts
* automatically so the LLM knows about them.
*/
export interface BuiltInAction {
/** Action name (e.g. "setState") */
name: string;
/** Human-readable description for the LLM */
description: string;
}
/**
* Schema options
*/
@@ -148,6 +162,13 @@ export interface SchemaOptions<TCatalog = unknown> {
promptTemplate?: PromptTemplate<TCatalog>;
/** Default rules baked into the schema (injected before customRules in prompts) */
defaultRules?: string[];
/**
* Built-in actions that are always available regardless of catalog configuration.
* These are injected into prompts automatically so the LLM knows about them,
* but they don't require handlers in defineRegistry because the runtime
* (e.g. ActionProvider) handles them directly.
*/
builtInActions?: BuiltInAction[];
}
/**
@@ -347,6 +368,7 @@ export function defineSchema<TDef extends SchemaDefinition>(
definition,
promptTemplate: options?.promptTemplate,
defaultRules: options?.defaultRules,
builtInActions: options?.builtInActions,
createCatalog<TCatalog extends InferCatalogInput<TDef["catalog"]>>(
catalog: TCatalog,
): Catalog<TDef, TCatalog> {
@@ -749,12 +771,26 @@ Note: state patches appear right after the elements that use them, so the UI fil
| Record<string, { params?: z.ZodType; description?: string }>
| undefined;
if (actions && catalog.actionNames.length > 0) {
const builtInActions = catalog.schema.builtInActions ?? [];
const hasCustomActions = actions && catalog.actionNames.length > 0;
const hasBuiltInActions = builtInActions.length > 0;
if (hasCustomActions || hasBuiltInActions) {
lines.push("AVAILABLE ACTIONS:");
lines.push("");
for (const [name, def] of Object.entries(actions)) {
lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
// Built-in actions (handled by runtime, always available)
for (const action of builtInActions) {
lines.push(`- ${action.name}: ${action.description} [built-in]`);
}
// Custom actions (declared in catalog, require handlers)
if (hasCustomActions) {
for (const [name, def] of Object.entries(actions)) {
lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
}
}
lines.push("");
}
+98
View File
@@ -1022,6 +1022,104 @@ describe("createJsonRenderTransform", () => {
const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
expect(specChunks.length).toBe(1);
});
// ===========================================================================
// Text block splitting around spec data
// ===========================================================================
it("splits text blocks around spec data (text-start/text-end pairs)", async () => {
const input = [
"Some text\n",
'{"op":"add","path":"/root","value":"r"}\n',
"More text\n",
].join("");
const chunks = await transformText(input);
const textStarts = chunks.filter((c) => c.type === "text-start");
const textEnds = chunks.filter((c) => c.type === "text-end");
// There should be two text blocks: one before the patch and one after
expect(textStarts.length).toBe(2);
expect(textEnds.length).toBe(2);
// Spec data should appear between the two text blocks
const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
expect(specChunks.length).toBe(1);
// Find the indices of the first text-end and the spec chunk
const firstTextEndIdx = chunks.findIndex((c) => c.type === "text-end");
const specIdx = chunks.findIndex((c) => c.type === SPEC_DATA_PART_TYPE);
const secondTextStartIdx = chunks.findIndex(
(c, i) => i > specIdx && c.type === "text-start",
);
expect(firstTextEndIdx).toBeLessThan(specIdx);
expect(specIdx).toBeLessThan(secondTextStartIdx);
});
it("flush closes an open text block when stream ends without text-end", async () => {
const transform = createJsonRenderTransform();
const writer = transform.writable.getWriter();
const reader = transform.readable.getReader();
const chunks: StreamChunk[] = [];
const readAll = (async () => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
})();
// Write text-start + text-delta, then close WITHOUT text-end
await writer.write({ type: "text-start", id: "t1" });
await writer.write({
type: "text-delta",
id: "t1",
delta: "Hello world\n",
});
await writer.close();
await readAll;
// The transform's flush should have emitted a text-end to close the block
const textEnds = chunks.filter((c) => c.type === "text-end");
expect(textEnds.length).toBe(1);
// Text content should still be present
const textChunks = chunks.filter((c) => c.type === "text-delta");
const text = textChunks.map((c) => (c as { delta: string }).delta).join("");
expect(text).toContain("Hello world");
});
it("consecutive patches do not produce empty text blocks", async () => {
const input = [
'{"op":"add","path":"/root","value":"r"}\n',
'{"op":"add","path":"/elements/r","value":{"type":"Card","props":{},"children":[]}}\n',
].join("");
const chunks = await transformText(input);
const specChunks = chunks.filter((c) => c.type === SPEC_DATA_PART_TYPE);
expect(specChunks.length).toBe(2);
// There should be no text-start/text-end pairs between the two spec chunks
// (the initial text-start from the upstream is forwarded, but no new empty ones)
const textDeltas = chunks.filter((c) => c.type === "text-delta");
const textContent = textDeltas
.map((c) => (c as { delta: string }).delta)
.join("")
.trim();
// No meaningful text content between the patches
expect(textContent).toBe("");
// Count text blocks: there should be at most 1 (the initial upstream one),
// not extra empty ones inserted between patches
const textStarts = chunks.filter((c) => c.type === "text-start");
const textEnds = chunks.filter((c) => c.type === "text-end");
expect(textStarts.length).toBeLessThanOrEqual(1);
expect(textEnds.length).toBeLessThanOrEqual(1);
});
});
// =============================================================================
+56 -38
View File
@@ -1010,11 +1010,48 @@ export function createJsonRenderTransform(): TransformStream<
let buffering = false;
// Whether we are inside a ```spec fence
let inSpecFence = false;
// Whether we are currently inside a text block (between text-start/text-end).
// Used to split text blocks around spec data so the AI SDK creates separate
// text parts, preserving interleaving of prose and UI in message.parts.
let inTextBlock = false;
let textIdCounter = 0;
/** Close the current text block if one is open. */
function closeTextBlock(
controller: TransformStreamDefaultController<StreamChunk>,
) {
if (inTextBlock) {
controller.enqueue({ type: "text-end", id: currentTextId });
inTextBlock = false;
}
}
/** Ensure a text block is open, starting a new one if needed. */
function ensureTextBlock(
controller: TransformStreamDefaultController<StreamChunk>,
) {
if (!inTextBlock) {
textIdCounter++;
currentTextId = String(textIdCounter);
controller.enqueue({ type: "text-start", id: currentTextId });
inTextBlock = true;
}
}
/** Emit a text-delta, opening a text block first if necessary. */
function emitTextDelta(
delta: string,
controller: TransformStreamDefaultController<StreamChunk>,
) {
ensureTextBlock(controller);
controller.enqueue({ type: "text-delta", id: currentTextId, delta });
}
function emitPatch(
patch: SpecStreamLine,
controller: TransformStreamDefaultController<StreamChunk>,
) {
closeTextBlock(controller);
controller.enqueue({
type: SPEC_DATA_PART_TYPE,
data: { type: "patch", patch },
@@ -1046,19 +1083,11 @@ export function createJsonRenderTransform(): TransformStream<
emitPatch(patch, controller);
} else {
// Was buffered but isn't JSONL — flush as text
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: lineBuffer,
});
emitTextDelta(lineBuffer, controller);
}
} else {
// Whitespace-only buffer — forward as-is (preserves blank lines)
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: lineBuffer,
});
emitTextDelta(lineBuffer, controller);
}
lineBuffer = "";
buffering = false;
@@ -1092,11 +1121,7 @@ export function createJsonRenderTransform(): TransformStream<
// --- Outside fence: heuristic mode ---
if (!trimmed) {
// Empty line — forward for markdown paragraph breaks
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: "\n",
});
emitTextDelta("\n", controller);
return;
}
@@ -1104,11 +1129,7 @@ export function createJsonRenderTransform(): TransformStream<
if (patch) {
emitPatch(patch, controller);
} else {
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: line + "\n",
});
emitTextDelta(line + "\n", controller);
}
}
@@ -1116,14 +1137,19 @@ export function createJsonRenderTransform(): TransformStream<
transform(chunk, controller) {
switch (chunk.type) {
case "text-start": {
currentTextId = (chunk as { id: string }).id;
const id = (chunk as { id: string }).id;
const idNum = parseInt(id, 10);
if (!isNaN(idNum) && idNum >= textIdCounter) {
textIdCounter = idNum;
}
currentTextId = id;
inTextBlock = true;
controller.enqueue(chunk);
break;
}
case "text-delta": {
const delta = chunk as { id: string; delta: string };
currentTextId = delta.id;
const text = delta.delta;
for (let i = 0; i < text.length; i++) {
@@ -1138,11 +1164,7 @@ export function createJsonRenderTransform(): TransformStream<
} else {
// Outside fence, emit newline; inside fence, swallow it
if (!inSpecFence) {
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: "\n",
});
emitTextDelta("\n", controller);
}
}
} else if (lineBuffer.length === 0 && !buffering) {
@@ -1152,20 +1174,12 @@ export function createJsonRenderTransform(): TransformStream<
buffering = true;
lineBuffer += ch;
} else {
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: ch,
});
emitTextDelta(ch, controller);
}
} else if (buffering) {
lineBuffer += ch;
} else {
controller.enqueue({
type: "text-delta",
id: currentTextId,
delta: ch,
});
emitTextDelta(ch, controller);
}
}
break;
@@ -1173,7 +1187,10 @@ export function createJsonRenderTransform(): TransformStream<
case "text-end": {
flushBuffer(controller);
controller.enqueue(chunk);
if (inTextBlock) {
controller.enqueue({ type: "text-end", id: currentTextId });
inTextBlock = false;
}
break;
}
@@ -1186,6 +1203,7 @@ export function createJsonRenderTransform(): TransformStream<
flush(controller) {
flushBuffer(controller);
closeTextBlock(controller);
},
});
}
+20
View File
@@ -1,5 +1,25 @@
# @json-render/react-native
## 0.7.0
### Patch Changes
- Updated dependencies [2d70fab]
- @json-render/core@0.7.0
## 0.6.1
### Patch Changes
- @json-render/core@0.6.1
## 0.6.1
### Patch Changes
- Updated dependencies [ea97aff]
- @json-render/core@0.6.1
## 0.6.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.6.0",
"version": "0.7.0",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
+65
View File
@@ -1,5 +1,70 @@
# @json-render/react
## 0.7.0
### Minor Changes
- 2d70fab: New `@json-render/shadcn` package, event handles, built-in actions, and stream improvements.
### New: `@json-render/shadcn` Package
Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 30+ components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
- `shadcnComponentDefinitions` — Zod-based catalog definitions for all components (server-safe, no React dependency via `@json-render/shadcn/catalog`)
- `shadcnComponents` — React implementations for all components
- Layout: Card, Stack, Grid, Separator
- Navigation: Tabs, Accordion, Collapsible, Pagination
- Overlay: Dialog, Drawer, Tooltip, Popover, DropdownMenu
- Content: Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table
- Feedback: Progress, Skeleton, Spinner
- Input: Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup
### New: Event Handles (`on()`)
Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata:
- `emit()` — fire the event
- `shouldPreventDefault` — whether any action binding requested `preventDefault`
- `bound` — whether any handler is bound to this event
### New: `BaseComponentProps`
Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog.
### New: Built-in Actions in Schema
Schemas can now declare `builtInActions` — actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`.
### New: `preventDefault` on `ActionBinding`
Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented.
### Improved: Stream Transform Text Block Splitting
`createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`.
### Improved: `defineRegistry` Actions Requirement
`defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object.
### Patch Changes
- Updated dependencies [2d70fab]
- @json-render/core@0.7.0
## 0.6.1
### Patch Changes
- 43ad534: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update.
- @json-render/core@0.6.1
## 0.6.1
### Patch Changes
- ea97aff: Fix infinite re-render loop caused by multiple unbound form inputs (Input, Textarea, Select) all registering field validation at the same empty path with different `checks` configs, causing them to overwrite each other endlessly. Stabilize context values in ActionProvider, ValidationProvider, and useUIStream by using refs for state/callbacks, preventing unnecessary re-render cascades on every state update.
- Updated dependencies [ea97aff]
- @json-render/core@0.6.1
## 0.6.0
### Minor Changes
+67 -2
View File
@@ -51,6 +51,8 @@ export const catalog = defineCatalog(schema, {
### 2. Define Component Implementations
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
```tsx
import { defineRegistry, useBoundProp } from "@json-render/react";
import { catalog } from "./catalog";
@@ -297,7 +299,7 @@ See [@json-render/core](../core/README.md) for full expression syntax.
## Built-in Actions
The `setState`, `pushState`, and `removeState` actions are handled automatically by `ActionProvider`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
The `setState`, `pushState`, and `removeState` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
```json
{
@@ -321,14 +323,52 @@ When using `defineRegistry`, components receive these props:
interface ComponentContext<P> {
props: P; // Typed props from the catalog (expressions resolved)
children?: React.ReactNode; // Rendered children
emit?: (event: string) => void; // Emit a named event
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the parent is loading
bindings?: Record<string, string>; // State paths for $bindState/$bindItem expressions (e.g. bindings.value)
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault` or `bound`:
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
```
Use `bindings?.value`, `bindings?.checked`, etc. with `useBoundProp()` for two-way bound form components.
### `BaseComponentProps`
For building reusable component libraries that are not tied to a specific catalog (e.g. `@json-render/shadcn`), use the catalog-agnostic `BaseComponentProps` type:
```typescript
import type { BaseComponentProps } from "@json-render/react";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
<div>{props.title}{children}</div>
);
```
## Generate AI Prompts
```typescript
@@ -374,3 +414,28 @@ function App() {
return <Renderer spec={spec} registry={registry} />;
}
```
## Key Exports
| Export | Purpose |
|--------|---------|
| `defineRegistry` | Create a type-safe component registry from a catalog |
| `Renderer` | Render a spec using a registry |
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) |
| `useStateStore` | Access state context |
| `useStateValue` | Get single value from state |
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
| `useActions` | Access actions context |
| `useAction` | Get a single action dispatch function |
| `useUIStream` | Stream specs from an API endpoint |
### Types
| Export | Purpose |
|--------|---------|
| `ComponentContext` | Typed component render function context (catalog-aware) |
| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries |
| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` |
| `ComponentFn` | Component render function type |
| `SetState` | State setter type |
| `StateModel` | State model type |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.6.0",
"version": "0.7.0",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
+60 -12
View File
@@ -25,6 +25,53 @@ export type SetState = (
// 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 (e.g. `@json-render/shadcn`)
* that are not tied to a specific catalog.
*
* @example
* ```ts
* const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
* <div>{props.title}{children}</div>
* );
* ```
*/
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
children?: ReactNode;
/** 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
@@ -35,18 +82,7 @@ export type SetState = (
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> {
props: InferComponentProps<C, K>;
children?: ReactNode;
/** Emit a named event. The renderer resolves the event to an action binding from the element's `on` field. */
emit: (event: string) => void;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
loading?: boolean;
}
> extends BaseComponentProps<InferComponentProps<C, K>> {}
/**
* Component render function type for React
@@ -104,3 +140,15 @@ export type ActionFn<
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;
+8 -2
View File
@@ -6,6 +6,7 @@ import React, {
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
@@ -137,6 +138,11 @@ export function ActionProvider({
children,
}: ActionProviderProps) {
const { state, get, set } = useStateStore();
// Keep a ref to the latest state so `execute` doesn't change on every
// state update — preventing the entire action context from churning.
const stateRef = useRef(state);
stateRef.current = state;
const [handlers, setHandlers] =
useState<Record<string, ActionHandler>>(initialHandlers);
const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
@@ -152,7 +158,7 @@ export function ActionProvider({
const execute = useCallback(
async (binding: ActionBinding) => {
const resolved = resolveAction(binding, state);
const resolved = resolveAction(binding, stateRef.current);
// Built-in: setState updates the StateProvider state directly
if (resolved.action === "setState" && resolved.params) {
@@ -299,7 +305,7 @@ export function ActionProvider({
});
}
},
[state, handlers, get, set, navigate],
[handlers, get, set, navigate],
);
const confirm = useCallback(() => {
+11 -4
View File
@@ -6,6 +6,7 @@ import React, {
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
@@ -130,6 +131,11 @@ export function ValidationProvider({
children,
}: ValidationProviderProps) {
const { state } = useStateStore();
// Keep a ref to the latest state so `validate` doesn't change on every
// state update — preventing the entire validation context from churning.
const stateRef = useRef(state);
stateRef.current = state;
const [fieldStates, setFieldStates] = useState<
Record<string, FieldValidationState>
>({});
@@ -155,8 +161,9 @@ export function ValidationProvider({
const validate = useCallback(
(path: string, config: ValidationConfig): ValidationResult => {
// Walk the nested state object using JSON Pointer segments
const currentState = stateRef.current;
const segments = path.split("/").filter(Boolean);
let value: unknown = state;
let value: unknown = currentState;
for (const seg of segments) {
if (value != null && typeof value === "object") {
value = (value as Record<string, unknown>)[seg];
@@ -167,7 +174,7 @@ export function ValidationProvider({
}
const result = runValidation(config, {
value,
stateModel: state,
stateModel: currentState,
customFunctions,
});
@@ -182,7 +189,7 @@ export function ValidationProvider({
return result;
},
[state, customFunctions],
[customFunctions],
);
const touch = useCallback((path: string) => {
@@ -280,7 +287,7 @@ export function useFieldValidation(
// Register field on mount
React.useEffect(() => {
if (config) {
if (path && config) {
registerField(path, config);
}
}, [path, config, registerField]);
+10 -3
View File
@@ -250,6 +250,13 @@ export function useUIStream({
const [rawLines, setRawLines] = useState<string[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
// Keep refs to callbacks so `send` doesn't recreate when consumers
// pass inline arrow functions.
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const clear = useCallback(() => {
setSpec(null);
setError(null);
@@ -350,19 +357,19 @@ export function useUIStream({
}
}
onComplete?.(currentSpec);
onCompleteRef.current?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
onError?.(error);
onErrorRef.current?.(error);
} finally {
setIsStreaming(false);
}
},
[api, onComplete, onError],
[api],
);
// Cleanup on unmount

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