Compare commits

..
Author SHA1 Message Date
github-actions[bot] 0b7d767cdd chore: version packages (#154)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-24 06:29:45 -06:00
Chris Tate b1036763d2 fixed: Install failure due to private dependency (#153)
* fix @internal/react-state import

* add changeset for @internal/react-state install fix

* docs: add v0.9.1 changelog entry
2026-02-24 06:25:34 -06:00
72 changed files with 361 additions and 3037 deletions
+9
View File
@@ -1,5 +1,14 @@
# web
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1
### Patch Changes
@@ -5,6 +5,16 @@ export const metadata = pageMetadata("docs/changelog")
Notable changes and updates to json-render.
## v0.9.1
February 2026
### Fixed: Install failure due to private dependency
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
---
## v0.9.0
February 2026
@@ -1,119 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/computed-values")
# Computed Values
Derive dynamic prop values using registered functions or string templates.
## `$template` — String Interpolation
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
},
"children": []
}
```
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
Missing paths resolve to an empty string.
## `$computed` — Registered Functions
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
```json
{
"type": "Text",
"props": {
"text": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
},
"children": []
}
```
### Registering Functions
Functions are registered in the catalog and provided at runtime.
**Catalog definition (for AI prompt generation):**
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
functions: {
fullName: {
description: 'Combines first and last name into a full name',
},
formatCurrency: {
description: 'Formats a number as currency',
},
},
});
```
**Runtime implementation:**
```tsx
import { JSONUIProvider } from '@json-render/react';
const functions = {
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
formatCurrency: (args) => {
const value = Number(args.value ?? 0);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: (args.currency as string) ?? 'USD',
}).format(value);
},
};
<JSONUIProvider registry={registry} functions={functions}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
### Using with `createRenderer`
```tsx
const MyRenderer = createRenderer(catalog, components);
<MyRenderer
spec={spec}
functions={functions}
/>
```
## Combining Expressions
`$computed` args can use any expression type. This example computes a total from repeat item fields:
```json
{
"$computed": "lineTotal",
"args": {
"price": { "$item": "price" },
"quantity": { "$item": "quantity" }
}
}
```
## Next
- [Watchers](/docs/watchers) — react to state changes with cascading actions
- [Data Binding](/docs/data-binding) — all expression types
- [Validation](/docs/validation) — validate form inputs
@@ -213,22 +213,6 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
The condition uses the same [visibility](/docs/visibility) expression format.
## Template Strings
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Welcome back, ${/user/name}!" }
},
"children": []
}
```
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
## Quick Reference
<div className="my-6 overflow-x-auto">
@@ -271,16 +255,6 @@ See [Computed Values](/docs/computed-values) for details on `$template` and `$co
<td><code>{'{ "$bindItem": "field" }'}</code></td>
<td>Form components inside repeat</td>
</tr>
<tr>
<td><code>{"$template"}</code></td>
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
<td>Anywhere (string props)</td>
</tr>
<tr>
<td><code>{"$computed"}</code></td>
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
<td>Anywhere (requires registered function)</td>
</tr>
</tbody>
</table>
</div>
+7 -114
View File
@@ -11,18 +11,11 @@ json-render includes common validation functions:
- `required` — Value must be non-empty
- `email` — Valid email format
- `minLength` — Minimum string length (args: `{ "min": N }`)
- `maxLength` — Maximum string length (args: `{ "max": N }`)
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
- `min` — Minimum numeric value (args: `{ "min": N }`)
- `max` — Maximum numeric value (args: `{ "max": N }`)
- `numeric` — Value must be a number
- `url` — Valid URL format
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
- `minLength` — Minimum string length
- `maxLength` — Maximum string length
- `pattern` — Match a regex pattern
- `min` — Minimum numeric value
- `max` — Maximum numeric value
## Using Validation in JSON
@@ -150,114 +143,14 @@ function TextField({ props, bindings }) {
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
## Cross-Field Validation
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
```json
{
"type": "Input",
"props": {
"label": "Confirm Password",
"value": { "$bindState": "/form/confirmPassword" },
"checks": [
{ "type": "required", "message": "Please confirm your password" },
{
"type": "matches",
"args": { "other": { "$state": "/form/password" } },
"message": "Passwords must match"
}
]
}
}
```
Other cross-field examples:
```json
{
"checks": [
{
"type": "greaterThan",
"args": { "other": { "$state": "/form/startDate" } },
"message": "End date must be after start date"
}
]
}
```
```json
{
"checks": [
{
"type": "requiredIf",
"args": { "field": { "$state": "/form/enableNotifications" } },
"message": "Email is required when notifications are enabled"
}
]
}
```
## Conditional Validation
Use the `enabled` field in the validation config to only run checks when a condition is met:
```json
{
"type": "Input",
"props": {
"label": "Company Name",
"value": { "$bindState": "/form/company" },
"checks": [
{ "type": "required", "message": "Company name is required" }
]
}
}
```
In the component implementation, you can pass `enabled` to `useFieldValidation`:
```typescript
useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
enabled: { "$state": "/form/accountType", eq: "business" },
});
```
This only validates the company name when the account type is "business".
## Validation Timing
Control when validation runs with `validateOn`:
- `change` — Validate on every input change
- `blur` — Validate when field loses focus (default for Input, Textarea)
- `blur` — Validate when field loses focus
- `submit` — Validate only on form submission
## Form-Level Validation
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
```json
{
"type": "Button",
"props": { "label": "Submit" },
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
},
"children": []
}
```
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
## Next
- [Computed Values](/docs/computed-values) — derive dynamic prop values
- [Watchers](/docs/watchers) — react to state changes
- [Generation Modes](/docs/generation-modes) — how AI generates specs
Learn about [generation modes](/docs/generation-modes).
-150
View File
@@ -1,150 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/watchers")
# Watchers
React to state changes by triggering actions when watched paths update.
## The `watch` Field
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
```json
{
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
},
"children": []
}
```
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
## Cascading Selects
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
```json
{
"root": "form",
"elements": {
"form": {
"type": "Stack",
"props": { "direction": "vertical", "gap": "md" },
"children": ["country-select", "city-select"]
},
"country-select": {
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": [
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
]
},
"children": []
},
"city-select": {
"type": "Select",
"props": {
"label": "City",
"value": { "$bindState": "/form/city" },
"options": { "$state": "/availableCities" },
"placeholder": "Select a city"
},
"children": []
}
},
"state": {
"form": { "country": "", "city": "" },
"availableCities": []
}
}
```
The watcher on `country-select` fires two actions when the country changes:
1. `loadCities` — fetches and writes city options to `/availableCities`
2. `setState` — resets the city selection
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
### Action Handler
```typescript
const handlers = {
loadCities: async (params) => {
const cities = await fetchCities(params.country);
// setState is called by the runtime to write the result
return cities;
},
};
```
Or with `defineRegistry`:
```typescript
const { registry, handlers } = defineRegistry(catalog, {
components: { /* ... */ },
actions: {
loadCities: async (params, setState) => {
const response = await fetch(`/api/cities?country=${params.country}`);
const cities = await response.json();
setState('/availableCities', cities);
},
},
});
```
## Multiple Watchers
An element can watch multiple state paths. Each path maps to one or more action bindings:
```json
{
"watch": {
"/form/startDate": { "action": "validateDateRange" },
"/form/endDate": { "action": "validateDateRange" },
"/form/quantity": [
{ "action": "recalculateTotal" },
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
]
}
}
```
## Behavior
- Watchers only fire on **value changes**, not on the initial render
- Comparison is by reference (`===`), not deep equality
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
- Multiple action bindings on the same path execute sequentially
## When to Use `watch` vs `on`
| Mechanism | Trigger | Use Case |
|-----------|---------|----------|
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
## Next
- [Data Binding](/docs/data-binding) — connect elements to state
- [Computed Values](/docs/computed-values) — derive prop values
- [Visibility](/docs/visibility) — conditionally show or hide elements
-2
View File
@@ -27,9 +27,7 @@ export const docsNavigation: NavSection[] = [
{ title: "Schemas", href: "/docs/schemas" },
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Computed Values", href: "/docs/computed-values" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Watchers", href: "/docs/watchers" },
{ title: "Validation", href: "/docs/validation" },
],
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"license": "Apache-2.0",
+9
View File
@@ -1,5 +1,14 @@
# example-chat
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-chat",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
+9
View File
@@ -1,5 +1,14 @@
# example-dashboard
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-dashboard",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
+9
View File
@@ -1,5 +1,14 @@
# example-no-ai
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
+41 -59
View File
@@ -2,31 +2,33 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import ConfettiExplosion from "react-confetti-explosion";
import { JSONUIProvider, Renderer } from "@json-render/react";
import type { Spec } from "@json-render/core";
import {
registry,
actionHandlers,
computedFunctions,
onConfetti,
} from "@/lib/render/registry";
Renderer,
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
} from "@json-render/react";
import type { Spec } from "@json-render/core";
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
import { examples } from "@/lib/examples";
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
return (
<JSONUIProvider
registry={registry}
initialState={spec.state ?? {}}
handlers={actionHandlers}
functions={computedFunctions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
<StateProvider initialState={spec.state ?? {}}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
export default function Page() {
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedIndex] = useState(0);
const selected = examples[selectedIndex]!;
const [confettiKey, setConfettiKey] = useState(0);
const [confettiActive, setConfettiActive] = useState(false);
@@ -39,50 +41,30 @@ export default function Page() {
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
return (
<div className="h-screen flex flex-col bg-muted/30">
{/* Example selector */}
<nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
{examples.map((ex, i) => (
<button
key={ex.name}
onClick={() => setSelectedIndex(i)}
className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
i === selectedIndex
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
{ex.name}
</button>
))}
</nav>
{/* Render area */}
<div className="flex-1 flex items-start justify-center overflow-auto p-6">
<div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
</div>
)}
<div className="p-6 relative z-10">
<p className="text-xs text-muted-foreground mb-4">
{selected.description}
</p>
<SpecRenderer key={selectedIndex} spec={selected.spec} />
<div className="h-screen flex items-center justify-center bg-muted/30">
<div
className="relative bg-background border rounded-lg shadow-sm"
style={{ width: 960, height: 1080 }}
>
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
</div>
)}
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div>
</div>
</div>
-349
View File
@@ -508,353 +508,4 @@ export const examples: Example[] = [
},
},
},
// =========================================================================
// Advanced: Registration form with cross-field validation & $template
// =========================================================================
{
name: "Registration Form",
description:
"Cross-field validation, $template preview, and validateForm action",
spec: {
root: "card",
state: {
form: {
name: "",
email: "",
password: "",
confirmPassword: "",
accountType: "personal",
company: "",
},
result: null,
},
elements: {
card: {
type: "Card",
props: {
title: "Create Account",
description: "Fill out the form below to register",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"preview",
"sep0",
"nameInput",
"emailInput",
"passwordInput",
"confirmInput",
"sep1",
"accountTypeRadio",
"companyInput",
"sep2",
"actions",
"statusText",
],
},
// $template live preview
preview: {
type: "Text",
props: {
text: {
$template: "Welcome, ${/form/name}! Your email: ${/form/email}",
},
variant: "muted",
},
visible: { $state: "/form/name", neq: "" },
children: [],
},
sep0: { type: "Separator", props: { orientation: null }, children: [] },
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Jane Doe",
value: { $bindState: "/form/name" },
checks: [
{ type: "required", message: "Name is required" },
{
type: "minLength",
args: { min: 2 },
message: "Name must be at least 2 characters",
},
],
validateOn: "blur",
},
children: [],
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "jane@example.com",
value: { $bindState: "/form/email" },
checks: [
{ type: "required", message: "Email is required" },
{ type: "email", message: "Enter a valid email address" },
],
validateOn: "blur",
},
children: [],
},
passwordInput: {
type: "Input",
props: {
label: "Password",
name: "password",
type: "password",
placeholder: "At least 8 characters",
value: { $bindState: "/form/password" },
checks: [
{ type: "required", message: "Password is required" },
{
type: "minLength",
args: { min: 8 },
message: "Password must be at least 8 characters",
},
],
validateOn: "blur",
},
children: [],
},
confirmInput: {
type: "Input",
props: {
label: "Confirm Password",
name: "confirmPassword",
type: "password",
placeholder: "Re-enter your password",
value: { $bindState: "/form/confirmPassword" },
checks: [
{ type: "required", message: "Please confirm your password" },
{
type: "matches",
args: { other: { $state: "/form/password" } },
message: "Passwords must match",
},
],
validateOn: "blur",
},
children: [],
},
sep1: { type: "Separator", props: { orientation: null }, children: [] },
accountTypeRadio: {
type: "Radio",
props: {
label: "Account Type",
name: "accountType",
options: ["personal", "business"],
value: { $bindState: "/form/accountType" },
checks: null,
validateOn: null,
},
children: [],
},
companyInput: {
type: "Input",
props: {
label: "Company Name",
name: "company",
type: "text",
placeholder: "Acme Inc.",
value: { $bindState: "/form/company" },
checks: [
{
type: "requiredIf",
args: { field: { $state: "/form/accountType" } },
message: "Company name is required for business accounts",
},
],
validateOn: "blur",
},
visible: { $state: "/form/accountType", eq: "business" },
children: [],
},
sep2: { type: "Separator", props: { orientation: null }, children: [] },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["submitBtn"],
},
submitBtn: {
type: "Button",
props: { label: "Register", variant: "primary", disabled: null },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
// Validation result
statusText: {
type: "Alert",
props: {
title: "Validation Result",
message: {
$cond: { $state: "/result/valid", eq: true },
$then: "All fields are valid -- ready to submit!",
$else: "Please fix the errors above before submitting.",
},
type: {
$cond: { $state: "/result/valid", eq: true },
$then: "success",
$else: "error",
},
},
visible: { $state: "/result", neq: null },
children: [],
},
},
},
},
// =========================================================================
// Advanced: Cascading selects with watchers & $computed
// =========================================================================
{
name: "Cascading Selects",
description:
"Watchers reset dependent fields, $computed derives display values",
spec: {
root: "card",
state: {
form: { country: "", city: "" },
availableCities: [],
},
elements: {
card: {
type: "Card",
props: {
title: "Shipping Address",
description: "Select your country to load available cities",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"countrySelect",
"citySelect",
"sep",
"addressPreview",
"templatePreview",
],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK", "Germany", "Japan"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
watch: {
"/form/country": [
{
action: "setState",
params: {
statePath: "/availableCities",
value: {
$computed: "citiesForCountry",
args: { country: { $state: "/form/country" } },
},
},
},
{
action: "setState",
params: { statePath: "/form/city", value: "" },
},
],
},
children: [],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: { $state: "/availableCities" },
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
sep: { type: "Separator", props: { orientation: null }, children: [] },
// $computed formatted address
addressPreview: {
type: "Heading",
props: {
text: {
$computed: "formatAddress",
args: {
city: { $state: "/form/city" },
country: { $state: "/form/country" },
},
},
level: "h3",
},
children: [],
},
// $template string interpolation
templatePreview: {
type: "Text",
props: {
text: {
$template:
"Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
},
variant: "muted",
},
visible: { $state: "/form/country", neq: "" },
children: [],
},
},
},
},
];
-9
View File
@@ -13,13 +13,4 @@ export const catalog = defineCatalog(schema, {
description: "Fire confetti",
},
},
functions: {
formatAddress: {
description:
"Formats country and city into a single address string like 'City, Country'",
},
citiesForCountry: {
description: "Returns an array of city names for the given country code",
},
},
});
+1 -27
View File
@@ -1,7 +1,6 @@
"use client";
import { defineRegistry } from "@json-render/react";
import type { ComputedFunction } from "@json-render/core";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
@@ -14,14 +13,6 @@ export function onConfetti(cb: () => void) {
};
}
const cityData: Record<string, string[]> = {
US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
};
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
@@ -33,23 +24,6 @@ export const { registry } = defineRegistry(catalog, {
},
});
export const actionHandlers: Record<
string,
(params: Record<string, unknown>) => void
> = {
export const actionHandlers: Record<string, () => void> = {
confetti: () => confettiListener?.(),
};
export const computedFunctions: Record<string, ComputedFunction> = {
formatAddress: (args) => {
const city = (args.city as string) ?? "";
const country = (args.country as string) ?? "";
if (!city && !country) return "No location selected";
if (!city) return country;
return `${city}, ${country}`;
},
citiesForCountry: (args) => {
const country = (args.country as string) ?? "";
return cityData[country] ?? [];
},
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-no-ai",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-react-native
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react-native@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"main": "expo-router/entry",
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-react-pdf
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react-pdf@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-pdf",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
+7
View File
@@ -1,5 +1,12 @@
# example-remotion
## 0.1.2
### Patch Changes
- @json-render/core@0.9.1
- @json-render/remotion@0.9.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-remotion",
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
@@ -1,5 +1,13 @@
# com.example.json-render-demo
## 0.0.3
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
## 0.0.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-demo",
"version": "0.0.2",
"version": "0.0.3",
"description": "Test",
"private": true,
"license": "~~proprietary~~",
@@ -1,5 +1,13 @@
# com.example.json-render-fullpage-demo
## 0.0.3
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
## 0.0.2
### Patch Changes
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.2",
"version": "0.0.3",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
+1 -1
View File
@@ -12,7 +12,7 @@
},
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --concurrency 20",
"dev": "turbo run dev --concurrency 15",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"type-check": "turbo run check-types",
+6
View File
@@ -1,5 +1,11 @@
# @json-render/codegen
## 0.9.1
### Patch Changes
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+2
View File
@@ -1,5 +1,7 @@
# @json-render/core
## 0.9.1
## 0.9.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
+1 -5
View File
@@ -72,11 +72,7 @@ export {
} from "./visibility";
// Prop Expressions
export type {
PropExpression,
PropResolutionContext,
ComputedFunction,
} from "./props";
export type { PropExpression, PropResolutionContext } from "./props";
export {
resolvePropValue,
+1 -165
View File
@@ -1,11 +1,9 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import {
resolvePropValue,
resolveElementProps,
resolveBindings,
resolveActionParam,
_resetWarnedComputedFns,
_resetWarnedTemplatePaths,
} from "./props";
import type { PropResolutionContext } from "./props";
@@ -500,165 +498,3 @@ describe("resolveActionParam", () => {
expect(resolveActionParam(null, ctx)).toBeNull();
});
});
// =============================================================================
// $computed expressions
// =============================================================================
describe("$computed expressions", () => {
it("calls a registered function with resolved args", () => {
const ctx: PropResolutionContext = {
stateModel: { form: { firstName: "Jane", lastName: "Doe" } },
functions: {
fullName: (args) => `${args.first} ${args.last}`,
},
};
expect(
resolvePropValue(
{
$computed: "fullName",
args: {
first: { $state: "/form/firstName" },
last: { $state: "/form/lastName" },
},
},
ctx,
),
).toBe("Jane Doe");
});
it("calls function with no args", () => {
const ctx: PropResolutionContext = {
stateModel: {},
functions: {
timestamp: () => 1234567890,
},
};
expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890);
});
it("returns undefined for unknown function", () => {
const ctx: PropResolutionContext = {
stateModel: {},
functions: {},
};
expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined();
});
it("returns undefined when no functions in context", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined();
});
it("deduplicates warnings for the same unknown function", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: {}, functions: {} };
resolvePropValue({ $computed: "dedupTest" }, ctx);
resolvePropValue({ $computed: "dedupTest" }, ctx);
const calls = warnSpy.mock.calls.filter((c) =>
String(c[0]).includes("dedupTest"),
);
expect(calls).toHaveLength(1);
warnSpy.mockRestore();
});
it("resolves nested expressions in args", () => {
const ctx: PropResolutionContext = {
stateModel: { active: true, values: { a: 10, b: 20 } },
functions: {
conditionalSum: (args) => {
if (args.enabled) return (args.x as number) + (args.y as number);
return 0;
},
},
};
expect(
resolvePropValue(
{
$computed: "conditionalSum",
args: {
enabled: { $state: "/active" },
x: { $state: "/values/a" },
y: { $state: "/values/b" },
},
},
ctx,
),
).toBe(30);
});
});
// =============================================================================
// $template expressions
// =============================================================================
describe("$template expressions", () => {
it("interpolates state values into a string", () => {
const ctx: PropResolutionContext = {
stateModel: { user: { name: "Alice" }, count: 3 },
};
expect(
resolvePropValue(
{ $template: "Hello, ${/user/name}! You have ${/count} messages." },
ctx,
),
).toBe("Hello, Alice! You have 3 messages.");
});
it("replaces missing paths with empty string", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !");
});
it("handles template with no interpolations", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe(
"No variables here",
);
});
it("handles multiple references to the same path", () => {
const ctx: PropResolutionContext = {
stateModel: { x: "A" },
};
expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe(
"A and A",
);
});
it("converts non-string values to strings", () => {
const ctx: PropResolutionContext = {
stateModel: { num: 42, bool: true },
};
expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe(
"42 is true",
);
});
it("warns when path does not start with /", () => {
_resetWarnedTemplatePaths();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
expect(result).toBe("Hi Bob!");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('$template path "name"'),
);
warnSpy.mockRestore();
_resetWarnedTemplatePaths();
});
it("deduplicates warnings for the same $template path", () => {
_resetWarnedTemplatePaths();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
const calls = warnSpy.mock.calls.filter((c) =>
String(c[0]).includes('$template path "name"'),
);
expect(calls).toHaveLength(1);
warnSpy.mockRestore();
_resetWarnedTemplatePaths();
});
});
+1 -100
View File
@@ -22,10 +22,6 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
* repeat item — resolves via `repeatBasePath + path` and exposes the
* absolute state path for write-back.
* - `{ $cond, $then, $else }` conditionally picks a value
* - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
* registered function with resolved args and returns the result
* - `{ $template: string }` interpolates `${/path}` references in the
* string with values from the state model
* - Any other value is a literal (passthrough)
*/
export type PropExpression<T = unknown> =
@@ -39,15 +35,7 @@ export type PropExpression<T = unknown> =
$cond: VisibilityCondition;
$then: PropExpression<T>;
$else: PropExpression<T>;
}
| { $computed: string; args?: Record<string, unknown> }
| { $template: string };
/**
* Function signature for `$computed` expressions.
* Receives a record of resolved argument values and returns a computed result.
*/
export type ComputedFunction = (args: Record<string, unknown>) => unknown;
};
/**
* Context for resolving prop expressions.
@@ -57,8 +45,6 @@ export type ComputedFunction = (args: Record<string, unknown>) => unknown;
export interface PropResolutionContext extends VisibilityContext {
/** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
repeatBasePath?: string;
/** Named functions available for `$computed` expressions. */
functions?: Record<string, ComputedFunction>;
}
// =============================================================================
@@ -124,47 +110,6 @@ function isCondExpression(
);
}
function isComputedExpression(
value: unknown,
): value is { $computed: string; args?: Record<string, unknown> } {
return (
typeof value === "object" &&
value !== null &&
"$computed" in value &&
typeof (value as Record<string, unknown>).$computed === "string"
);
}
function isTemplateExpression(value: unknown): value is { $template: string } {
return (
typeof value === "object" &&
value !== null &&
"$template" in value &&
typeof (value as Record<string, unknown>).$template === "string"
);
}
// Module-level set to avoid spamming console.warn on every render for the same
// unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX,
// new names are no longer deduplicated (warnings still fire) but the set stops
// growing, preventing unbounded memory use in long-lived processes (e.g. SSR).
const WARNED_COMPUTED_MAX = 100;
const warnedComputedFns = new Set<string>();
/** @internal Test-only: clear the deduplication set for $computed warnings. */
export function _resetWarnedComputedFns(): void {
warnedComputedFns.clear();
}
// Same deduplication pattern for $template paths that don't start with "/".
const WARNED_TEMPLATE_MAX = 100;
const warnedTemplatePaths = new Set<string>();
/** @internal Test-only: clear the deduplication set for $template warnings. */
export function _resetWarnedTemplatePaths(): void {
warnedTemplatePaths.clear();
}
// =============================================================================
// Prop Expression Resolution
// =============================================================================
@@ -248,50 +193,6 @@ export function resolvePropValue(
return resolvePropValue(result ? value.$then : value.$else, ctx);
}
// $computed: call a registered function with resolved args
if (isComputedExpression(value)) {
const fn = ctx.functions?.[value.$computed];
if (!fn) {
if (!warnedComputedFns.has(value.$computed)) {
if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
warnedComputedFns.add(value.$computed);
}
console.warn(`Unknown $computed function: "${value.$computed}"`);
}
return undefined;
}
const resolvedArgs: Record<string, unknown> = {};
if (value.args) {
for (const [key, arg] of Object.entries(value.args)) {
resolvedArgs[key] = resolvePropValue(arg, ctx);
}
}
return fn(resolvedArgs);
}
// $template: interpolate ${/path} references with state values
if (isTemplateExpression(value)) {
return value.$template.replace(
/\$\{([^}]+)\}/g,
(_match, rawPath: string) => {
let path = rawPath;
if (!path.startsWith("/")) {
if (!warnedTemplatePaths.has(path)) {
if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
warnedTemplatePaths.add(path);
}
console.warn(
`$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
);
}
path = "/" + path;
}
const resolved = getByPath(ctx.stateModel, path);
return resolved != null ? String(resolved) : "";
},
);
}
// Arrays: resolve each element
if (Array.isArray(value)) {
return value.map((item) => resolvePropValue(item, ctx));
+1 -65
View File
@@ -900,31 +900,6 @@ Note: state patches appear right after the elements that use them, so the UI fil
"Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.",
);
lines.push("");
lines.push(
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
);
lines.push(
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
);
lines.push("");
// $computed section — only emit when catalog defines functions
const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
lines.push(
'5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.',
);
lines.push(
' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`',
);
lines.push(" Available functions:");
for (const name of Object.keys(
catalogFunctions as Record<string, unknown>,
)) {
lines.push(` - ${name}`);
}
lines.push("");
}
// Validation section — only emit when at least one component has a `checks` prop
const hasChecksComponents = allComponents
@@ -955,19 +930,7 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push(" - numeric — value must be a number");
lines.push(" - url — valid URL format");
lines.push(
' - matches — must equal another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - equalTo — alias for matches (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })',
' - matches — must equal another field (args: { "other": "value" })',
);
lines.push("");
lines.push("Example:");
@@ -984,33 +947,6 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push("");
}
// State watchers section — only emit when actions are available (watchers
// trigger actions, so the section is irrelevant without them).
if (hasCustomActions || hasBuiltInActions) {
lines.push("STATE WATCHERS:");
lines.push(
"Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props.",
);
lines.push(
"Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.",
);
lines.push("");
lines.push(
"Example (cascading select — country changes trigger city loading):",
);
lines.push(
` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`,
);
lines.push("");
lines.push(
"Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).",
);
lines.push(
"IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render.",
);
lines.push("");
}
// Rules
lines.push("RULES:");
const baseRules =
-250
View File
@@ -1,250 +0,0 @@
import { describe, it, expect } from "vitest";
import type { Spec } from "./types";
import { validateSpec, autoFixSpec } from "./spec-validator";
// =============================================================================
// validateSpec
// =============================================================================
describe("validateSpec", () => {
it("returns valid for a correct spec", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["child1"] },
child1: { type: "Text", props: { text: "hello" }, children: [] },
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("detects missing root", () => {
const spec = {
root: "",
elements: { a: { type: "T", props: {}, children: [] } },
} as Spec;
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "missing_root")).toBe(true);
});
it("detects root_not_found", () => {
const spec: Spec = {
root: "missing",
elements: { a: { type: "T", props: {}, children: [] } },
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true);
});
it("detects empty spec", () => {
const spec: Spec = { root: "r", elements: {} };
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true);
});
it("detects missing_child", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["nonexistent"] },
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
});
it("detects visible_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Text",
props: { visible: { $state: "/show" } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true);
});
it("detects on_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: { on: { press: { action: "doSomething" } } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true);
});
it("detects repeat_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { repeat: { statePath: "/items" } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true);
});
it("detects watch_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Select",
props: {
watch: {
"/form/country": { action: "loadCities" },
},
},
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
const watchIssue = result.issues.find((i) => i.code === "watch_in_props");
expect(watchIssue).toBeDefined();
expect(watchIssue!.elementKey).toBe("root");
});
it("detects orphaned elements when checkOrphans is true", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: [] },
orphan: { type: "Text", props: {}, children: [] },
},
};
const result = validateSpec(spec, { checkOrphans: true });
expect(result.valid).toBe(true);
expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true);
});
});
// =============================================================================
// autoFixSpec
// =============================================================================
describe("autoFixSpec", () => {
it("moves visible from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Text",
props: { text: "hi", visible: { $state: "/show" } },
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).visible,
).toBeUndefined();
expect(fixed.elements.root.visible).toEqual({ $state: "/show" });
expect(fixes.some((f) => f.includes("visible"))).toBe(true);
});
it("moves on from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: { label: "OK", on: { press: { action: "submit" } } },
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).on,
).toBeUndefined();
expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } });
expect(fixes.some((f) => f.includes('"on"'))).toBe(true);
});
it("moves repeat from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { repeat: { statePath: "/items" } },
children: ["child"],
},
child: { type: "Text", props: {}, children: [] },
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).repeat,
).toBeUndefined();
expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" });
expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true);
});
it("moves watch from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Select",
props: {
label: "Country",
watch: {
"/form/country": { action: "loadCities" },
},
},
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).watch,
).toBeUndefined();
expect(fixed.elements.root.watch).toEqual({
"/form/country": { action: "loadCities" },
});
expect(fixes.some((f) => f.includes('"watch"'))).toBe(true);
});
it("returns no fixes for a correct spec", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { direction: "vertical" },
children: [],
watch: { "/x": { action: "y" } },
},
},
};
const { fixes } = autoFixSpec(spec);
expect(fixes).toHaveLength(0);
});
});
+1 -27
View File
@@ -28,8 +28,7 @@ export interface SpecIssue {
| "orphaned_element"
| "empty_spec"
| "on_in_props"
| "repeat_in_props"
| "watch_in_props";
| "repeat_in_props";
}
/**
@@ -152,16 +151,6 @@ export function validateSpec(
code: "repeat_in_props",
});
}
// 3e. `watch` inside props (should be a top-level field)
if (props && "watch" in props && props.watch !== undefined) {
issues.push({
severity: "error",
message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
elementKey: key,
code: "watch_in_props",
});
}
}
// 4. Orphaned elements (optional)
@@ -259,21 +248,6 @@ export function autoFixSpec(spec: Spec): {
fixes.push(`Moved "repeat" from props to element level on "${key}".`);
}
currentProps = fixed.props as Record<string, unknown> | undefined;
if (
currentProps &&
"watch" in currentProps &&
currentProps.watch !== undefined
) {
const { watch, ...restProps } = currentProps;
fixed = {
...fixed,
props: restProps,
watch: watch as UIElement["watch"],
};
fixes.push(`Moved "watch" from props to element level on "${key}".`);
}
fixedElements[key] = fixed;
}
-6
View File
@@ -69,12 +69,6 @@ export interface UIElement<
on?: Record<string, ActionBinding | ActionBinding[]>;
/** Repeat children once per item in a state array */
repeat?: { statePath: string; key?: string };
/**
* State watchers — maps JSON Pointer state paths to action bindings.
* When the value at a watched path changes, the bound actions fire.
* Useful for cascading dependencies (e.g. country → city option loading).
*/
watch?: Record<string, ActionBinding | ActionBinding[]>;
}
/**
-341
View File
@@ -187,226 +187,6 @@ describe("builtInValidationFunctions", () => {
).toBe(false);
});
});
describe("equalTo", () => {
it("passes when values are equal", () => {
expect(builtInValidationFunctions.equalTo("abc", { other: "abc" })).toBe(
true,
);
});
it("fails when values differ", () => {
expect(builtInValidationFunctions.equalTo("abc", { other: "xyz" })).toBe(
false,
);
});
});
describe("lessThan", () => {
it("passes when value is less than other", () => {
expect(builtInValidationFunctions.lessThan(3, { other: 5 })).toBe(true);
});
it("fails when value equals other", () => {
expect(builtInValidationFunctions.lessThan(5, { other: 5 })).toBe(false);
});
it("fails when value is greater than other", () => {
expect(builtInValidationFunctions.lessThan(7, { other: 5 })).toBe(false);
});
it("coerces numeric string vs number", () => {
expect(builtInValidationFunctions.lessThan("3", { other: 5 })).toBe(true);
});
it("fails coercion when non-numeric string", () => {
expect(builtInValidationFunctions.lessThan("abc", { other: 5 })).toBe(
false,
);
});
it("passes for string comparison (ISO dates)", () => {
expect(
builtInValidationFunctions.lessThan("2026-01-01", {
other: "2026-06-15",
}),
).toBe(true);
});
it("fails for equal strings", () => {
expect(
builtInValidationFunctions.lessThan("2026-01-01", {
other: "2026-01-01",
}),
).toBe(false);
});
it("returns false when value is empty string", () => {
expect(builtInValidationFunctions.lessThan("", { other: 5 })).toBe(false);
});
it("returns false when other is empty string", () => {
expect(builtInValidationFunctions.lessThan(3, { other: "" })).toBe(false);
});
it("returns false when value is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.lessThan("", { other: "abc" })).toBe(
false,
);
});
it("returns false when other is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.lessThan("abc", { other: "" })).toBe(
false,
);
});
it("returns false when other is null", () => {
expect(builtInValidationFunctions.lessThan(3, { other: null })).toBe(
false,
);
});
it("returns false when value is null", () => {
expect(builtInValidationFunctions.lessThan(null, { other: 5 })).toBe(
false,
);
});
it("returns false when other is undefined", () => {
expect(builtInValidationFunctions.lessThan(3, { other: undefined })).toBe(
false,
);
});
});
describe("greaterThan", () => {
it("passes when value is greater than other", () => {
expect(builtInValidationFunctions.greaterThan(7, { other: 5 })).toBe(
true,
);
});
it("fails when value equals other", () => {
expect(builtInValidationFunctions.greaterThan(5, { other: 5 })).toBe(
false,
);
});
it("fails when value is less than other", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: 5 })).toBe(
false,
);
});
it("coerces numeric string vs number", () => {
expect(builtInValidationFunctions.greaterThan("7", { other: 5 })).toBe(
true,
);
});
it("fails coercion when non-numeric string", () => {
expect(builtInValidationFunctions.greaterThan("abc", { other: 5 })).toBe(
false,
);
});
it("passes for string comparison (ISO dates)", () => {
expect(
builtInValidationFunctions.greaterThan("2026-06-15", {
other: "2026-01-01",
}),
).toBe(true);
});
it("fails for lesser strings", () => {
expect(
builtInValidationFunctions.greaterThan("2026-01-01", {
other: "2026-06-15",
}),
).toBe(false);
});
it("returns false when value is empty string", () => {
expect(builtInValidationFunctions.greaterThan("", { other: 5 })).toBe(
false,
);
});
it("returns false when other is empty string", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: "" })).toBe(
false,
);
});
it("returns false when value is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.greaterThan("", { other: "abc" })).toBe(
false,
);
});
it("returns false when other is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.greaterThan("abc", { other: "" })).toBe(
false,
);
});
it("returns false when other is null", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: null })).toBe(
false,
);
});
it("returns false when value is undefined", () => {
expect(
builtInValidationFunctions.greaterThan(undefined, { other: 5 }),
).toBe(false);
});
it("returns false when value is null", () => {
expect(builtInValidationFunctions.greaterThan(null, { other: 5 })).toBe(
false,
);
});
});
describe("requiredIf", () => {
it("passes when condition is falsy (field not required)", () => {
expect(builtInValidationFunctions.requiredIf("", { field: false })).toBe(
true,
);
expect(builtInValidationFunctions.requiredIf("", { field: "" })).toBe(
true,
);
expect(builtInValidationFunctions.requiredIf("", { field: null })).toBe(
true,
);
expect(
builtInValidationFunctions.requiredIf("", { field: undefined }),
).toBe(true);
});
it("fails when condition is truthy and value is empty", () => {
expect(builtInValidationFunctions.requiredIf("", { field: true })).toBe(
false,
);
expect(
builtInValidationFunctions.requiredIf(null, { field: "yes" }),
).toBe(false);
expect(
builtInValidationFunctions.requiredIf(undefined, { field: 1 }),
).toBe(false);
});
it("passes when condition is truthy and value is present", () => {
expect(
builtInValidationFunctions.requiredIf("hello", { field: true }),
).toBe(true);
expect(builtInValidationFunctions.requiredIf(42, { field: true })).toBe(
true,
);
});
});
});
describe("runValidationCheck", () => {
@@ -611,22 +391,6 @@ describe("check helper", () => {
});
});
describe("numeric", () => {
it("creates numeric check with default message", () => {
const c = check.numeric();
expect(c.type).toBe("numeric");
expect(c.message).toBe("Must be a number");
});
it("creates numeric check with custom message", () => {
const c = check.numeric("Numbers only");
expect(c.type).toBe("numeric");
expect(c.message).toBe("Numbers only");
});
});
describe("matches", () => {
it("creates matches check with path reference", () => {
const c = check.matches("/password", "Passwords must match");
@@ -636,109 +400,4 @@ describe("check helper", () => {
expect(c.message).toBe("Passwords must match");
});
});
describe("equalTo", () => {
it("creates equalTo check with path reference", () => {
const c = check.equalTo("/email", "Emails must match");
expect(c.type).toBe("equalTo");
expect(c.args).toEqual({ other: { $state: "/email" } });
expect(c.message).toBe("Emails must match");
});
});
describe("lessThan", () => {
it("creates lessThan check with path reference", () => {
const c = check.lessThan("/maxValue", "Must be less");
expect(c.type).toBe("lessThan");
expect(c.args).toEqual({ other: { $state: "/maxValue" } });
expect(c.message).toBe("Must be less");
});
});
describe("greaterThan", () => {
it("creates greaterThan check with path reference", () => {
const c = check.greaterThan("/minValue");
expect(c.type).toBe("greaterThan");
expect(c.args).toEqual({ other: { $state: "/minValue" } });
});
});
describe("requiredIf", () => {
it("creates requiredIf check with path reference", () => {
const c = check.requiredIf("/toggle", "Required when toggle is on");
expect(c.type).toBe("requiredIf");
expect(c.args).toEqual({ field: { $state: "/toggle" } });
expect(c.message).toBe("Required when toggle is on");
});
});
});
// =============================================================================
// Deep arg resolution in runValidationCheck
// =============================================================================
describe("deep arg resolution", () => {
it("resolves nested $state refs in validation args", () => {
const result = runValidationCheck(
{
type: "matches",
args: { other: { $state: "/form/password" } },
message: "Passwords must match",
},
{
value: "secret123",
stateModel: { form: { password: "secret123" } },
},
);
expect(result.valid).toBe(true);
});
it("resolves $state in cross-field lessThan check", () => {
const result = runValidationCheck(
{
type: "lessThan",
args: { other: { $state: "/form/maxPrice" } },
message: "Must be less than max price",
},
{
value: 50,
stateModel: { form: { maxPrice: 100 } },
},
);
expect(result.valid).toBe(true);
});
it("resolves $state in requiredIf check", () => {
const result = runValidationCheck(
{
type: "requiredIf",
args: { field: { $state: "/form/enableEmail" } },
message: "Email is required",
},
{
value: "",
stateModel: { form: { enableEmail: true } },
},
);
expect(result.valid).toBe(false);
});
it("passes requiredIf when condition is false", () => {
const result = runValidationCheck(
{
type: "requiredIf",
args: { field: { $state: "/form/enableEmail" } },
message: "Email is required",
},
{
value: "",
stateModel: { form: { enableEmail: false } },
},
);
expect(result.valid).toBe(true);
});
});
+4 -104
View File
@@ -2,7 +2,6 @@ import { z } from "zod";
import type { DynamicValue, StateModel, VisibilityCondition } from "./types";
import { DynamicValueSchema, resolveDynamicValue } from "./types";
import { VisibilityConditionSchema, evaluateVisibility } from "./visibility";
import { resolvePropValue } from "./props";
/**
* Validation check definition
@@ -64,14 +63,6 @@ export interface ValidationFunctionDefinition {
description?: string;
}
const matchesImpl: ValidationFunction = (
value: unknown,
args?: Record<string, unknown>,
) => {
const other = args?.other;
return value === other;
};
/**
* Built-in validation functions
*/
@@ -173,64 +164,9 @@ export const builtInValidationFunctions: Record<string, ValidationFunction> = {
/**
* Check if value matches another field
*/
matches: matchesImpl,
/**
* Alias for matches with a more descriptive name for cross-field equality
*/
equalTo: matchesImpl,
/**
* Check if value is less than another field's value.
* Supports numbers, strings (useful for ISO date comparison), and
* cross-type numeric coercion (e.g. string "3" vs number 5).
*/
lessThan: (value: unknown, args?: Record<string, unknown>) => {
matches: (value: unknown, args?: Record<string, unknown>) => {
const other = args?.other;
if (value == null || other == null || value === "" || other === "")
return false;
if (typeof value === "number" && typeof other === "number")
return value < other;
if (typeof value === "string" && typeof other === "string")
return value < other;
const numVal = Number(value);
const numOther = Number(other);
if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther;
return false;
},
/**
* Check if value is greater than another field's value.
* Supports numbers, strings (useful for ISO date comparison), and
* cross-type numeric coercion (e.g. string "7" vs number 5).
*/
greaterThan: (value: unknown, args?: Record<string, unknown>) => {
const other = args?.other;
if (value == null || other == null || value === "" || other === "")
return false;
if (typeof value === "number" && typeof other === "number")
return value > other;
if (typeof value === "string" && typeof other === "string")
return value > other;
const numVal = Number(value);
const numOther = Number(other);
if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther;
return false;
},
/**
* Required only when a condition is met.
* Uses JS truthiness: 0, false, "", null, and undefined are all
* treated as "condition not met" (field not required), matching
* the visibility system's bare-condition semantics.
*/
requiredIf: (value: unknown, args?: Record<string, unknown>) => {
const condition = args?.field;
if (!condition) return true;
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return true;
return value === other;
},
};
@@ -273,12 +209,11 @@ export function runValidationCheck(
): ValidationCheckResult {
const { value, stateModel, customFunctions } = ctx;
// Resolve args using resolvePropValue so nested $state refs (and any other
// prop expressions) are handled consistently with the rest of the system.
// Resolve args
const resolvedArgs: Record<string, unknown> = {};
if (check.args) {
for (const [key, argValue] of Object.entries(check.args)) {
resolvedArgs[key] = resolvePropValue(argValue, { stateModel });
resolvedArgs[key] = resolveDynamicValue(argValue, stateModel);
}
}
@@ -391,11 +326,6 @@ export const check = {
message,
}),
numeric: (message = "Must be a number"): ValidationCheck => ({
type: "numeric",
message,
}),
matches: (
otherPath: string,
message = "Fields must match",
@@ -404,34 +334,4 @@ export const check = {
args: { other: { $state: otherPath } },
message,
}),
equalTo: (
otherPath: string,
message = "Fields must match",
): ValidationCheck => ({
type: "equalTo",
args: { other: { $state: otherPath } },
message,
}),
lessThan: (otherPath: string, message?: string): ValidationCheck => ({
type: "lessThan",
args: { other: { $state: otherPath } },
message: message ?? "Must be less than the compared field",
}),
greaterThan: (otherPath: string, message?: string): ValidationCheck => ({
type: "greaterThan",
args: { other: { $state: otherPath } },
message: message ?? "Must be greater than the compared field",
}),
requiredIf: (
fieldPath: string,
message = "This field is required",
): ValidationCheck => ({
type: "requiredIf",
args: { field: { $state: fieldPath } },
message,
}),
};
+6
View File
@@ -1,5 +1,11 @@
# @json-render/jotai
## 0.9.1
### Patch Changes
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/jotai",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-native
## 0.9.1
### Patch Changes
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
@@ -56,10 +56,10 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@internal/react-state": "workspace:*"
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/react-state": "workspace:*",
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"react-native": "0.83.1",
+2 -1
View File
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/schema.ts", "src/catalog.ts"],
format: ["cjs", "esm"],
dts: true,
dts: { resolve: ["@internal/react-state"] },
sourcemap: true,
clean: true,
noExternal: ["@internal/react-state"],
external: ["react", "react-native", "@json-render/core", "zod"],
});
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-pdf
## 0.9.1
### Patch Changes
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-pdf",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
"keywords": [
@@ -61,10 +61,10 @@
},
"dependencies": {
"@json-render/core": "workspace:*",
"@internal/react-state": "workspace:*",
"@react-pdf/renderer": "^4.3.2"
},
"devDependencies": {
"@internal/react-state": "workspace:*",
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.0.2",
+2 -1
View File
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
format: ["cjs", "esm"],
dts: true,
dts: { resolve: ["@internal/react-state"] },
sourcemap: true,
clean: true,
noExternal: ["@internal/react-state"],
external: ["react", "@json-render/core", "@react-pdf/renderer", "zod"],
});
+6
View File
@@ -1,5 +1,11 @@
# @internal/react-state
## 0.8.2
### Patch Changes
- @json-render/core@0.9.1
## 0.8.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@internal/react-state",
"version": "0.8.1",
"version": "0.8.2",
"private": true,
"license": "Apache-2.0",
"description": "Shared React state context for json-render renderer packages",
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react
## 0.9.1
### Patch Changes
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
@@ -50,10 +50,10 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@internal/react-state": "workspace:*"
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/react-state": "workspace:*",
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.0.2",
+2 -25
View File
@@ -17,7 +17,6 @@ import {
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useOptionalValidation } from "./validation";
/**
* Generate a unique ID for use with the "$id" token.
@@ -138,7 +137,6 @@ export function ActionProvider({
children,
}: ActionProviderProps) {
const { get, set, getSnapshot } = useStateStore();
const validation = useOptionalValidation();
const [handlers, setHandlers] =
useState<Record<string, ActionHandler>>(initialHandlers);
@@ -229,34 +227,13 @@ export function ActionProvider({
if (previousScreen) {
set("/currentScreen", previousScreen);
} else {
// Sentinel empty string = clear currentScreen (return to default)
set("/currentScreen", undefined);
}
}
return;
}
// Built-in: validateForm triggers validateAll from the ValidationProvider
// and writes the result to a state path (default: /formValidation).
// IMPORTANT: validateAll() is synchronous — it runs all registered field
// validations and returns immediately. This guarantees that the next action
// in a sequential list (e.g. [validateForm, submitForm]) can read the
// validation result from state without awaiting an extra tick.
if (resolved.action === "validateForm") {
const validateAll = validation?.validateAll;
if (!validateAll) {
console.warn(
"validateForm action was dispatched but no ValidationProvider is connected. " +
"Ensure ValidationProvider is rendered inside the provider tree.",
);
return;
}
const valid = validateAll();
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid });
return;
}
const handler = handlers[resolved.action];
if (!handler) {
@@ -323,7 +300,7 @@ export function ActionProvider({
});
}
},
[handlers, get, set, getSnapshot, navigate, validation],
[handlers, get, set, getSnapshot, navigate],
);
const confirm = useCallback(() => {
+9 -14
View File
@@ -6,6 +6,7 @@ import React, {
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import {
@@ -129,7 +130,11 @@ export function ValidationProvider({
customFunctions = {},
children,
}: ValidationProviderProps) {
const { state, getSnapshot } = useStateStore();
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,10 +160,8 @@ export function ValidationProvider({
const validate = useCallback(
(path: string, config: ValidationConfig): ValidationResult => {
// Read from the store directly so validation sees values written in the
// same synchronous handler (e.g. setValue then validate in onChange).
// Using React state would return the stale pre-render snapshot.
const currentState = getSnapshot();
// Walk the nested state object using JSON Pointer segments
const currentState = stateRef.current;
const segments = path.split("/").filter(Boolean);
let value: unknown = currentState;
for (const seg of segments) {
@@ -186,7 +189,7 @@ export function ValidationProvider({
return result;
},
[customFunctions, getSnapshot],
[customFunctions],
);
const touch = useCallback((path: string) => {
@@ -260,14 +263,6 @@ export function useValidation(): ValidationContextValue {
return ctx;
}
/**
* Non-throwing variant of useValidation.
* Returns null when no ValidationProvider is present.
*/
export function useOptionalValidation(): ValidationContextValue | null {
return useContext(ValidationContext);
}
/**
* Hook to get validation state for a field
*/
-770
View File
@@ -1,770 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import React, { useState, useCallback, useMemo } from "react";
import { render, act, fireEvent, screen } from "@testing-library/react";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer";
import { useStateStore } from "./contexts/state";
import { useFieldValidation } from "./contexts/validation";
import { useBoundProp } from "./hooks";
// =============================================================================
// Stub components
// =============================================================================
function Button({ element, emit }: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => emit("press")}>
{element.props.label}
</button>
);
}
function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
const value = element.props.text;
return (
<span data-testid="text">
{value == null
? ""
: typeof value === "string"
? value
: JSON.stringify(value)}
</span>
);
}
function InputField({
element,
bindings,
}: ComponentRenderProps<{
label?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
}>) {
const props = element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState("");
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const config = useMemo(
() => (hasValidation ? { checks: props.checks ?? [] } : undefined),
[hasValidation, props.checks],
);
const { errors } = useFieldValidation(bindings?.value ?? "", config);
return (
<div>
{props.label && <label>{props.label}</label>}
<input
data-testid="input"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{errors.length > 0 && <span data-testid="input-error">{errors[0]}</span>}
</div>
);
}
function SelectField({
element,
bindings,
}: ComponentRenderProps<{ label?: string; value?: string }>) {
const props = element.props;
const [boundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
return <span data-testid="select-value">{boundValue ?? ""}</span>;
}
/**
* Select stub that mirrors the real shadcn Select validation behavior:
* calls setValue then validate() synchronously in onValueChange.
*/
function ValidatedSelect({
element,
bindings,
emit,
}: ComponentRenderProps<{
label?: string;
name?: string;
options?: string[];
placeholder?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
validateOn?: "change" | "blur" | "submit";
}>) {
const props = element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState("");
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.value && props.checks?.length);
const config = useMemo(
() =>
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
[hasValidation, props.checks, validateOn],
);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
config,
);
const options = props.options ?? [];
return (
<div>
{props.label && <label>{props.label}</label>}
<select
data-testid={`select-${props.name ?? "default"}`}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (hasValidation && validateOn === "change") validate();
emit("change");
}}
>
<option value="">{props.placeholder ?? "Select..."}</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{errors.length > 0 && (
<span data-testid={`select-error-${props.name ?? "default"}`}>
{errors[0]}
</span>
)}
</div>
);
}
function StateProbe() {
const { state } = useStateStore();
return <pre data-testid="state-probe">{JSON.stringify(state)}</pre>;
}
const registry = { Button, Text, Input: InputField, Select: SelectField };
function getState(): Record<string, unknown> {
return JSON.parse(screen.getByTestId("state-probe").textContent!);
}
// =============================================================================
// $computed expressions in rendering
// =============================================================================
describe("$computed expressions in rendering", () => {
it("resolves a $computed prop using provided functions", async () => {
const spec: Spec = {
state: { first: "Jane", last: "Doe" },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$computed: "fullName",
args: {
first: { $state: "/first" },
last: { $state: "/last" },
},
},
},
children: [],
},
},
};
const functions = {
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
};
render(
<JSONUIProvider
registry={registry}
initialState={spec.state}
functions={functions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
});
it("renders gracefully when functions prop is omitted", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $computed: "missing" },
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("");
warnSpy.mockRestore();
});
});
// =============================================================================
// $template expressions in rendering
// =============================================================================
describe("$template expressions in rendering", () => {
it("interpolates state values into a template string", () => {
const spec: Spec = {
state: { user: { name: "Alice" }, count: 3 },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$template: "Hello, ${/user/name}! You have ${/count} messages.",
},
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe(
"Hello, Alice! You have 3 messages.",
);
});
it("resolves missing paths to empty string", () => {
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $template: "Hi ${/name}!" },
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("Hi !");
});
});
// =============================================================================
// Watchers
// =============================================================================
describe("watchers (watch field)", () => {
it("does not fire on initial render, fires when watched state changes", async () => {
const loadCities = vi.fn(async (params: Record<string, unknown>) => {
// no-op, just tracking the call
});
const spec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
// Add watcher as a child of a wrapper so both render
const wrapperSpec: Spec = {
...spec,
root: "wrapper",
elements: {
...spec.elements,
wrapper: {
type: "Button",
props: { label: "wrapper" },
children: ["main", "watcher"],
},
},
};
// Use a Stack-like wrapper -- but since we only have Button/Text/Select
// stubs, we need a container. Let's add a simple Stack stub.
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => {
return <div data-testid="stack">{children}</div>;
};
const reg = { ...registry, Stack };
const stackSpec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
render(
<JSONUIProvider
registry={reg}
initialState={stackSpec.state}
handlers={{ loadCities }}
>
<Renderer spec={stackSpec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
// Not called on initial render
expect(loadCities).not.toHaveBeenCalled();
// Change the watched state path
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
expect(loadCities).toHaveBeenCalledTimes(1);
expect(loadCities).toHaveBeenCalledWith(
expect.objectContaining({ country: "US" }),
);
});
it("fires multiple action bindings on the same watch path", async () => {
const action1 = vi.fn();
const action2 = vi.fn();
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: [
{
action: "setState",
params: { statePath: "/value", value: "b" },
},
],
},
children: [],
},
watcher: {
type: "Text",
props: { text: { $state: "/value" } },
watch: {
"/value": [{ action: "action1" }, { action: "action2" }],
},
children: [],
},
},
};
render(
<JSONUIProvider
registry={reg}
initialState={spec.state}
handlers={{ action1, action2 }}
>
<Renderer spec={spec} registry={reg} />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
expect(action1).toHaveBeenCalledTimes(1);
expect(action2).toHaveBeenCalledTimes(1);
});
});
// =============================================================================
// validateForm action
// =============================================================================
describe("validateForm action", () => {
it("writes { valid: false } when a required field is empty", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
// Click submit with empty email
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.result).toEqual({ valid: false });
});
it("writes { valid: true } when all fields pass validation", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "test@example.com" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.result).toEqual({ valid: true });
});
it("defaults to /formValidation when no statePath is provided", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { name: "filled" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["nameInput", "submitBtn"],
},
nameInput: {
type: "Input",
props: {
label: "Name",
value: { $bindState: "/form/name" },
checks: [{ type: "required", message: "Required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [{ action: "validateForm" }],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.formValidation).toEqual({ valid: true });
});
});
// =============================================================================
// Select validate-on-change timing (#151)
// =============================================================================
describe("Select validate-on-change sees the new value, not the stale value", () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const regWithSelect = {
...registry,
Stack,
Select: ValidatedSelect,
};
it("does not show 'required' error when selecting the first value", async () => {
const spec: Spec = {
state: { form: { country: "" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["countrySelect"],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>,
);
// Select "US" for the first time (from empty)
await act(async () => {
fireEvent.change(screen.getByTestId("select-country"), {
target: { value: "US" },
});
});
// The value should be set in state
const state = getState();
expect((state.form as Record<string, unknown>).country).toBe("US");
// No validation error should appear -- "US" is non-empty
expect(screen.queryByTestId("select-error-country")).toBeNull();
});
it("does not show 'required' error when selecting the first city after country change resets it", async () => {
const spec: Spec = {
state: {
form: { country: "US", city: "" },
availableCities: ["New York", "Chicago"],
},
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["citySelect"],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: ["New York", "Chicago"],
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>,
);
// Select "New York" for the first time (from empty)
await act(async () => {
fireEvent.change(screen.getByTestId("select-city"), {
target: { value: "New York" },
});
});
const state = getState();
expect((state.form as Record<string, unknown>).city).toBe("New York");
// No validation error should appear
expect(screen.queryByTestId("select-error-city")).toBeNull();
});
});
-1
View File
@@ -30,7 +30,6 @@ export {
export {
ValidationProvider,
useValidation,
useOptionalValidation,
useFieldValidation,
type ValidationContextValue,
type ValidationProviderProps,
+31 -137
View File
@@ -5,9 +5,7 @@ import React, {
type ErrorInfo,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import type {
UIElement,
@@ -16,7 +14,6 @@ import type {
Catalog,
SchemaDefinition,
StateStore,
ComputedFunction,
} from "@json-render/core";
import {
resolveElementProps,
@@ -139,19 +136,6 @@ class ElementErrorBoundary extends React.Component<
}
}
// ---------------------------------------------------------------------------
// FunctionsContext – provides $computed functions to the element tree
// ---------------------------------------------------------------------------
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
const FunctionsContext =
React.createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
function useFunctions(): Record<string, ComputedFunction> {
return React.useContext(FunctionsContext);
}
interface ElementRendererProps {
element: UIElement;
spec: Spec;
@@ -174,22 +158,21 @@ const ElementRenderer = React.memo(function ElementRenderer({
const repeatScope = useRepeatScope();
const { ctx } = useVisibility();
const { execute } = useActions();
const { getSnapshot, state: watchState } = useStateStore();
const functions = useFunctions();
const { getSnapshot } = useStateStore();
// Build context with repeat scope and $computed functions
const fullCtx: PropResolutionContext = useMemo(() => {
const base: PropResolutionContext = repeatScope
? {
...ctx,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
}
: { ...ctx };
base.functions = functions;
return base;
}, [ctx, repeatScope, functions]);
// Build context with repeat scope (used for both visibility and props)
const fullCtx: PropResolutionContext = useMemo(
() =>
repeatScope
? {
...ctx,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
}
: ctx,
[ctx, repeatScope],
);
// Evaluate visibility (now supports $item/$index inside repeat scopes)
const isVisible =
@@ -244,85 +227,6 @@ const ElementRenderer = React.memo(function ElementRenderer({
[onBindings, emit],
);
// Watch effect: fire actions when watched state paths change.
// Must be called before any early return to satisfy Rules of Hooks.
//
// Two refs serve distinct roles:
// - `stableWatchRef` (useMemo): holds the last emitted values object so we
// can return the same reference when watched values haven't changed,
// preventing the downstream useEffect from firing on unrelated state updates.
// - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot
// for change detection. Starts as `null` to skip the initial mount.
const watchConfig = element.watch;
const prevWatchValues = useRef<Record<string, unknown> | null>(null);
const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
const watchedValues = useMemo(() => {
if (!watchConfig) return undefined;
const values: Record<string, unknown> = {};
for (const path of Object.keys(watchConfig)) {
values[path] = getByPath(watchState, path);
}
const prev = stableWatchRef.current;
if (prev) {
const keys = Object.keys(values);
if (
keys.length === Object.keys(prev).length &&
keys.every((k) => values[k] === prev[k])
) {
return prev;
}
}
stableWatchRef.current = values;
return values;
}, [watchConfig, watchState]);
useEffect(() => {
if (!watchConfig || !watchedValues) return;
const paths = Object.keys(watchConfig);
if (paths.length === 0) return;
const prev = prevWatchValues.current;
prevWatchValues.current = watchedValues;
// Skip the initial mount — only fire on changes
if (prev === null) return;
let cancelled = false;
void (async () => {
for (const path of paths) {
if (cancelled) break;
if (watchedValues[path] !== prev[path]) {
const binding = watchConfig[path];
if (!binding) continue;
const bindings = Array.isArray(binding) ? binding : [binding];
for (const b of bindings) {
if (cancelled) break;
if (!b.params) {
await execute(b);
if (cancelled) break;
continue;
}
const liveCtx: PropResolutionContext = {
...fullCtx,
stateModel: getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
if (cancelled) break;
}
}
}
})().catch(console.error);
return () => {
cancelled = true;
};
}, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]);
// Don't render if not visible
if (!isVisible) {
return null;
@@ -515,8 +419,6 @@ export interface JSONUIProviderProps {
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
children: ReactNode;
@@ -532,7 +434,6 @@ export function JSONUIProvider({
handlers,
navigate,
validationFunctions,
functions,
onStateChange,
children,
}: JSONUIProviderProps) {
@@ -543,14 +444,12 @@ export function JSONUIProvider({
onStateChange={onStateChange}
>
<VisibilityProvider>
<ValidationProvider customFunctions={validationFunctions}>
<ActionProvider handlers={handlers} navigate={navigate}>
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
{children}
<ConfirmationDialogManager />
</FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
<ActionProvider handlers={handlers} navigate={navigate}>
<ValidationProvider customFunctions={validationFunctions}>
{children}
<ConfirmationDialogManager />
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
@@ -751,8 +650,6 @@ export interface CreateRendererProps {
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
/** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
/** Fallback component for unknown types */
@@ -806,7 +703,6 @@ export function createRenderer<
state,
onAction,
onStateChange,
functions,
loading,
fallback,
}: CreateRendererProps) {
@@ -834,19 +730,17 @@ export function createRenderer<
onStateChange={onStateChange}
>
<VisibilityProvider>
<ValidationProvider>
<ActionProvider handlers={actionHandlers}>
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
<Renderer
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
<ConfirmationDialogManager />
</FunctionsContext.Provider>
</ActionProvider>
</ValidationProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider>
<Renderer
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
<ConfirmationDialogManager />
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
-5
View File
@@ -67,11 +67,6 @@ export const schema = defineSchema(
description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
},
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
},
],
defaultRules: [
// Element integrity
+2 -1
View File
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/schema.ts"],
format: ["cjs", "esm"],
dts: true,
dts: { resolve: ["@internal/react-state"] },
sourcemap: true,
clean: true,
noExternal: ["@internal/react-state"],
external: ["react", "react-dom", "@json-render/core"],
});
+6
View File
@@ -1,5 +1,11 @@
# @json-render/redux
## 0.9.1
### Patch Changes
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/redux",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "Redux adapter for json-render StateStore",
"keywords": [
+6
View File
@@ -1,5 +1,11 @@
# @json-render/remotion
## 0.9.1
### Patch Changes
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
+8
View File
@@ -1,5 +1,13 @@
# @json-render/shadcn
## 0.9.1
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/shadcn",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
"keywords": [
+33 -37
View File
@@ -1,21 +1,5 @@
import { z } from "zod";
// =============================================================================
// Shared validation schemas used across form components
// =============================================================================
const validationCheckSchema = z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable();
const validateOnSchema = z.enum(["change", "blur", "submit"]).nullable();
// =============================================================================
// shadcn/ui Component Definitions
// =============================================================================
@@ -288,12 +272,19 @@ export const shadcnComponentDefinitions = {
type: z.enum(["text", "email", "password", "number"]).nullable(),
placeholder: z.string().nullable(),
value: z.string().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
events: ["submit", "focus", "blur"],
description:
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength). validateOn controls timing (default: blur).",
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
example: {
label: "Email",
name: "email",
@@ -309,11 +300,18 @@ export const shadcnComponentDefinitions = {
placeholder: z.string().nullable(),
rows: z.number().nullable(),
value: z.string().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
description:
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: blur).",
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
},
Select: {
@@ -323,12 +321,19 @@ export const shadcnComponentDefinitions = {
options: z.array(z.string()),
placeholder: z.string().nullable(),
value: z.string().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
checks: z
.array(
z.object({
type: z.string(),
message: z.string(),
args: z.record(z.string(), z.unknown()).optional(),
}),
)
.nullable(),
}),
events: ["change"],
description:
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
},
Checkbox: {
@@ -336,12 +341,9 @@ export const shadcnComponentDefinitions = {
label: z.string(),
name: z.string(),
checked: z.boolean().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
}),
events: ["change"],
description:
"Checkbox input. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
description: "Checkbox input. Use { $bindState } on checked for binding.",
},
Radio: {
@@ -350,12 +352,9 @@ export const shadcnComponentDefinitions = {
name: z.string(),
options: z.array(z.string()),
value: z.string().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
}),
events: ["change"],
description:
"Radio button group. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
description: "Radio button group. Use { $bindState } on value for binding.",
},
Switch: {
@@ -363,12 +362,9 @@ export const shadcnComponentDefinitions = {
label: z.string(),
name: z.string(),
checked: z.boolean().nullable(),
checks: validationCheckSchema,
validateOn: validateOnSchema,
}),
events: ["change"],
description:
"Toggle switch. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
description: "Toggle switch. Use { $bindState } on checked for binding.",
},
Slider: {
+32 -79
View File
@@ -664,12 +664,11 @@ export const shadcnComponents = {
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "blur";
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
@@ -683,16 +682,13 @@ export const shadcnComponents = {
type={props.type ?? "text"}
placeholder={props.placeholder ?? ""}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (hasValidation && validateOn === "change") validate();
}}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") emit("submit");
}}
onFocus={() => emit("focus")}
onBlur={() => {
if (hasValidation && validateOn === "blur") validate();
if (hasValidation) validate();
emit("blur");
}}
/>
@@ -715,12 +711,11 @@ export const shadcnComponents = {
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "blur";
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
@@ -734,12 +729,9 @@ export const shadcnComponents = {
placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (hasValidation && validateOn === "change") validate();
}}
onChange={(e) => setValue(e.target.value)}
onBlur={() => {
if (hasValidation && validateOn === "blur") validate();
if (hasValidation) validate();
}}
/>
{errors.length > 0 && (
@@ -766,12 +758,11 @@ export const shadcnComponents = {
const options = rawOptions.map((opt) =>
typeof opt === "string" ? opt : String(opt ?? ""),
);
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
hasValidation ? { checks: props.checks ?? [] } : undefined,
);
return (
@@ -781,8 +772,7 @@ export const shadcnComponents = {
value={value}
onValueChange={(v) => {
setValue(v);
// Select has no native blur event, so only validate on "change"
if (hasValidation && validateOn === "change") validate();
if (hasValidation) validate();
emit("change");
}}
>
@@ -818,32 +808,19 @@ export const shadcnComponents = {
const checked = isBound ? (boundChecked ?? false) : localChecked;
const setChecked = isBound ? setBoundChecked : setLocalChecked;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.checked && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.checked ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
);
return (
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Checkbox
id={props.name ?? undefined}
checked={checked}
onCheckedChange={(c) => {
setChecked(c === true);
if (hasValidation && validateOn === "change") validate();
emit("change");
}}
/>
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
{props.label}
</Label>
</div>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
<div className="flex items-center space-x-2">
<Checkbox
id={props.name ?? undefined}
checked={checked}
onCheckedChange={(c) => {
setChecked(c === true);
emit("change");
}}
/>
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
{props.label}
</Label>
</div>
);
},
@@ -866,13 +843,6 @@ export const shadcnComponents = {
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.value && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
);
return (
<div className="space-y-2">
{props.label && <Label>{props.label}</Label>}
@@ -880,7 +850,6 @@ export const shadcnComponents = {
value={value}
onValueChange={(v) => {
setValue(v);
if (hasValidation && validateOn === "change") validate();
emit("change");
}}
>
@@ -899,9 +868,6 @@ export const shadcnComponents = {
</div>
))}
</RadioGroup>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
</div>
);
},
@@ -920,32 +886,19 @@ export const shadcnComponents = {
const checked = isBound ? (boundChecked ?? false) : localChecked;
const setChecked = isBound ? setBoundChecked : setLocalChecked;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.checked && props.checks?.length);
const { errors, validate } = useFieldValidation(
bindings?.checked ?? "",
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
);
return (
<div className="space-y-1">
<div className="flex items-center justify-between space-x-2">
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
{props.label}
</Label>
<Switch
id={props.name ?? undefined}
checked={checked}
onCheckedChange={(c) => {
setChecked(c);
if (hasValidation && validateOn === "change") validate();
emit("change");
}}
/>
</div>
{errors.length > 0 && (
<p className="text-sm text-destructive">{errors[0]}</p>
)}
<div className="flex items-center justify-between space-x-2">
<Label htmlFor={props.name ?? undefined} className="cursor-pointer">
{props.label}
</Label>
<Switch
id={props.name ?? undefined}
checked={checked}
onCheckedChange={(c) => {
setChecked(c);
emit("change");
}}
/>
</div>
);
},
+6
View File
@@ -1,5 +1,11 @@
# @json-render/zustand
## 0.9.1
### Patch Changes
- @json-render/core@0.9.1
## 0.9.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/zustand",
"version": "0.9.0",
"version": "0.9.1",
"license": "Apache-2.0",
"description": "Zustand adapter for json-render StateStore",
"keywords": [
+12 -22
View File
@@ -885,9 +885,6 @@ importers:
packages/react:
dependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@json-render/core':
specifier: workspace:*
version: link:../core
@@ -895,6 +892,9 @@ importers:
specifier: ^19.2.3
version: 19.2.3
devDependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
@@ -910,9 +910,6 @@ importers:
packages/react-native:
dependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@json-render/core':
specifier: workspace:*
version: link:../core
@@ -920,6 +917,9 @@ importers:
specifier: ^18.0.0 || ^19.0.0
version: 19.2.4
devDependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
@@ -941,9 +941,6 @@ importers:
packages/react-pdf:
dependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@json-render/core':
specifier: workspace:*
version: link:../core
@@ -954,6 +951,9 @@ importers:
specifier: ^18.0.0 || ^19.0.0
version: 19.2.4
devDependencies:
'@internal/react-state':
specifier: workspace:*
version: link:../react-state
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
@@ -12775,7 +12775,6 @@ snapshots:
- graphql
- supports-color
- utf-8-validate
optional: true
'@expo/code-signing-certificates@0.0.6':
dependencies:
@@ -12840,7 +12839,6 @@ snapshots:
optionalDependencies:
react: 19.2.4
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
'@expo/env@2.0.8':
dependencies:
@@ -12910,7 +12908,7 @@ snapshots:
postcss: 8.4.49
resolve-from: 5.0.0
optionalDependencies:
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- bufferutil
- supports-color
@@ -12991,7 +12989,7 @@ snapshots:
'@expo/json-file': 10.0.8
'@react-native/normalize-colors': 0.81.5
debug: 4.4.3
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
resolve-from: 5.0.0
semver: 7.7.3
xml2js: 0.6.0
@@ -13019,7 +13017,6 @@ snapshots:
expo-font: 14.0.11(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
'@expo/ws-tunnel@1.0.6': {}
@@ -17338,7 +17335,7 @@ snapshots:
resolve-from: 5.0.0
optionalDependencies:
'@babel/runtime': 7.28.6
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
transitivePeerDependencies:
- '@babel/core'
- supports-color
@@ -18665,7 +18662,6 @@ snapshots:
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
transitivePeerDependencies:
- supports-color
optional: true
expo-clipboard@8.0.8(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -18690,7 +18686,6 @@ snapshots:
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
transitivePeerDependencies:
- supports-color
optional: true
expo-file-system@19.0.21(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)):
dependencies:
@@ -18701,7 +18696,6 @@ snapshots:
dependencies:
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
expo-font@14.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -18716,7 +18710,6 @@ snapshots:
fontfaceobserver: 2.3.0
react: 19.2.4
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
expo-keep-awake@15.0.8(expo@54.0.33)(react@19.1.0):
dependencies:
@@ -18727,7 +18720,6 @@ snapshots:
dependencies:
expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
react: 19.2.4
optional: true
expo-linking@8.0.11(expo@54.0.33)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -18769,7 +18761,6 @@ snapshots:
invariant: 2.2.4
react: 19.2.4
react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
optional: true
expo-router@6.0.23(@expo/metro-runtime@6.1.2)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(expo-constants@18.0.13)(expo-linking@8.0.11)(expo@54.0.33)(react-dom@19.2.4(react@19.1.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.11.1(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
@@ -18932,7 +18923,6 @@ snapshots:
- graphql
- supports-color
- utf-8-validate
optional: true
exponential-backoff@3.1.3: {}