Address review: RN final-validation error path, repeat prune guard, docs

- react-native useUIStream mirrors ink: when retries are exhausted and the
  spec still fails validation, report through onError instead of silently
  calling onComplete with an invalid spec.
- autoFixSpec never prunes a repeat container to zero children; that would
  trade missing_child for repeat_without_children and leave the last-resort
  spec unrenderable. The dangling template reference stays visible to repair.
- Docs for the new surface: core README + skill (validateSpec issue codes,
  fixDetails, lossy option), react README + skill and web visibility docs
  (filtered-list pattern, mixed-condition splitting, framework support note).
This commit is contained in:
Chris Tate
2026-06-10 18:12:57 -05:00
parent 2f7cd1229c
commit 3af511168e
8 changed files with 85 additions and 5 deletions
@@ -199,6 +199,24 @@ With comparison:
This shows the divider for every item except the first (index 0).
### Filtered lists — `$item` on the repeat container
Putting an `$item` condition directly on the element that declares `repeat` filters which items render. This is the natural way to build kanban columns, tabbed lists, or status sections from one state array:
```json
{
"type": "Stack",
"repeat": { "statePath": "/tasks", "key": "id" },
"visible": { "$item": "status", "eq": "todo" },
"children": ["task-card"]
}
```
One child renders per matching item; non-matching items are skipped. When the condition is an `$and` (or array) that mixes scopes, the `$state` parts gate the container itself (a false gate hides the whole shell) while the `$item`/`$index` parts filter items. A mixed `$or` cannot be split and is applied entirely per item.
Filtered lists are currently implemented by the React renderer; other renderers evaluate the container condition outside the repeat scope.
`$item` and `$index` conditions support the same comparison operators as `$state` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`).
## Complex Example
+11 -2
View File
@@ -230,7 +230,7 @@ Schema options:
| Export | Purpose |
|--------|---------|
| `validateSpec(spec, options?)` | Validate spec structure and return issues |
| `autoFixSpec(spec)` | Auto-fix common spec issues (returns corrected copy) |
| `autoFixSpec(spec, options?)` | Auto-fix common spec issues; `fixDetails` classifies each fix as lossy or lossless, `{ lossy: false }` withholds pruning |
| `formatSpecIssues(issues)` | Format validation issues as readable strings |
### Actions
@@ -553,7 +553,16 @@ const { valid, issues } = validateSpec(spec);
console.log(formatSpecIssues(issues));
// Auto-fix common issues (returns a corrected copy)
const fixed = autoFixSpec(spec);
const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec);
```
`validateSpec` checks structure beyond the catalog schema: missing or dangling `children` references, malformed `visible` conditions (anything outside the documented forms evaluates to hidden at runtime, so it is rejected with code `invalid_visible`), `repeat` containers with no children (`repeat_without_children`), and `repeat.statePath` values that do not reference an array in the spec's own `state` (`repeat_state_mismatch`).
`autoFixSpec` distinguishes lossless fixes (relocating `visible`/`on`/`repeat`/`watch` out of `props`) from lossy ones (pruning `children` references to elements that were never defined). Each entry in `fixDetails` carries `{ message, lossy }`. Callers with a repair loop should apply lossless fixes immediately and prefer re-prompting over lossy fixes, passing `{ lossy: false }` to withhold pruning until retries are exhausted:
```typescript
const lastAttempt = retriesUsed >= maxRetries;
const { spec: fixed, fixDetails } = autoFixSpec(spec, { lossy: lastAttempt });
```
## State Watchers
+22
View File
@@ -322,6 +322,28 @@ describe("autoFixSpec", () => {
expect(fixes).toEqual([]);
});
it("does not prune a repeat container down to zero children", () => {
const spec: Spec = {
root: "list",
state: { items: [{ id: "1" }] },
elements: {
list: {
type: "Stack",
props: {},
repeat: { statePath: "/items" },
children: ["ghost"],
},
},
};
const { spec: fixed, fixDetails } = autoFixSpec(spec);
expect(fixed.elements.list!.children).toEqual(["ghost"]);
expect(fixDetails).toEqual([]);
// The real problem (missing template) stays visible to the repair loop.
const result = validateSpec(fixed);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
});
it("withholds lossy fixes when options.lossy is false", () => {
const spec: Spec = {
root: "root",
+6
View File
@@ -361,6 +361,12 @@ export function autoFixSpec(
(child) => child in fixedElements,
);
if (present.length === element.children.length) continue;
if (element.repeat !== undefined && present.length === 0) {
// Pruning every child of a repeat container would only trade the
// missing_child error for repeat_without_children; keep the dangling
// reference so repair targets the real problem (the missing template).
continue;
}
for (const child of element.children) {
if (!(child in fixedElements)) {
fixes.push(
+15
View File
@@ -563,6 +563,21 @@ export function useUIStream({
// continue loop
}
// If retries were exhausted and validation still fails, report error
// instead of silently treating partial/invalid specs as complete.
if (enableValidation && retriesUsed >= maxRetries && currentSpec.root) {
const finalValidation = validateSpec(currentSpec);
if (!finalValidation.valid) {
const issueText = formatSpecIssues(finalValidation.issues);
const validationError = new Error(
`Spec validation failed after ${maxRetries} retries:\n${issueText}`,
);
setError(validationError);
onError?.(validationError);
return;
}
}
onComplete?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") {
+3 -1
View File
@@ -323,7 +323,9 @@ Any prop value can use data-driven expressions that resolve at render time. The
}
```
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead.
To render a filtered list (kanban columns, status sections), put `repeat` and an `$item` visibility condition on the same container: `{ "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] }` renders one child per matching item. AND-composed `$state` conjuncts still gate the container itself. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
### `$template` and `$computed`
+9 -2
View File
@@ -176,7 +176,14 @@ Validate spec structure and auto-fix common issues:
import { validateSpec, autoFixSpec } from "@json-render/core";
const { valid, issues } = validateSpec(spec);
const fixed = autoFixSpec(spec);
// issues include: missing_child, invalid_visible (malformed conditions),
// repeat_without_children, repeat_state_mismatch (statePath not an array in state)
const { spec: fixed, fixDetails } = autoFixSpec(spec);
// fixDetails entries are { message, lossy }. Lossless fixes relocate
// misplaced fields; lossy fixes prune dangling children references.
// In a repair loop, withhold lossy fixes until retries are exhausted:
const attempt = autoFixSpec(spec, { lossy: retriesExhausted });
```
## Visibility Conditions
@@ -253,7 +260,7 @@ The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`,
| `diffToPatches` | Generate RFC 6902 JSON Patch operations from object diff |
| `EditMode` | Type: `"patch" \| "merge" \| "diff"` |
| `validateSpec` | Validate spec structure |
| `autoFixSpec` | Auto-fix common spec issues |
| `autoFixSpec` | Auto-fix common spec issues; classifies fixes lossy/lossless, `{ lossy: false }` withholds pruning |
| `createSpecStreamCompiler` | Stream JSONL patches into spec |
| `createJsonRenderTransform` | TransformStream separating text from JSONL in mixed streams |
| `parseSpecStreamLine` | Parse single JSONL line |
+1
View File
@@ -118,6 +118,7 @@ Any prop value can be a data-driven expression resolved by the renderer before c
- **`{ "$state": "/state/key" }`** - reads from state model (one-way read)
- **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components.
- **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes.
- **Filtered lists**: `repeat` plus an `$item` visible condition on the same container renders only matching items: `{ "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] }`. AND-composed `$state` conjuncts gate the container shell; `$item`/`$index` conjuncts filter items.
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - conditional value
- **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings
- **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args