mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 03:54:41 +08:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user