Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/app/(main)/docs/api/core/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ interface UIElement {
children?: string[]; // Keys of child elements
visible?: VisibilityCondition;
on?: Record<string, ActionBinding | ActionBinding[]>; // Event bindings
repeat?: { statePath: string; key?: string }; // Repeat for arrays
repeat?: { statePath: string | { $item: string }; key?: string }; // Repeat for arrays
}
```

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(main)/docs/data-binding/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ The `repeat` field on an element renders its children once per item in a state a
}
```

- `repeat.statePath` JSON Pointer to the state array
- `repeat.statePath`: root JSON Pointer to the state array, or `{ "$item": "field" }` for an array on the enclosing repeat item
- `repeat.key` — field name on each item to use as a stable key for rendering

Inside `todo-item`, `{ "$item": "title" }` reads the `title` field from whichever array item is currently being rendered. `{ "$index": true }` would return `0` for the first item, `1` for the second, and so on.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ console.log(formatSpecIssues(issues));
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`).
`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`), relative repeat paths outside an enclosing repeat (`repeat_item_outside_scope`), 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:

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type {
DynamicString,
DynamicNumber,
DynamicBoolean,
RepeatStatePath,
UIElement,
FlatElement,
Spec,
Expand Down Expand Up @@ -38,6 +39,8 @@ export {
DynamicBooleanSchema,
resolveDynamicValue,
getByPath,
resolveRepeatStatePath,
resolveRepeatItemStatePath,
setByPath,
addByPath,
removeByPath,
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/repeat-schema-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import type { Schema, SchemaType } from "./schema";
import { schema as imageSchema } from "../../image/src/schema";
import { schema as inkSchema } from "../../ink/src/schema";
import { schema as reactEmailSchema } from "../../react-email/src/schema";
import { schema as reactNativeSchema } from "../../react-native/src/schema";
import { schema as reactPdfSchema } from "../../react-pdf/src/schema";
import { schema as reactSchema } from "../../react/src/schema";
import { schema as solidSchema } from "../../solid/src/schema";
import { schema as svelteSchema } from "../../svelte/src/schema";
import { schema as vueSchema } from "../../vue/src/schema";

const schemas: Record<string, Schema> = {
image: imageSchema,
ink: inkSchema,
react: reactSchema,
"react-email": reactEmailSchema,
"react-native": reactNativeSchema,
"react-pdf": reactPdfSchema,
solid: solidSchema,
svelte: svelteSchema,
vue: vueSchema,
};

describe("renderer schema repeat parity", () => {
it.each(Object.entries(schemas))(
"%s declares repeat as an optional element field",
(_name, schema) => {
const spec = schema.definition.spec as SchemaType<
"object",
Record<string, SchemaType>
>;
const elements = spec.inner?.elements as SchemaType<
"record",
SchemaType<"object", Record<string, SchemaType>>
>;
const repeat = elements.inner?.inner?.repeat;

expect(repeat?.kind).toBe("any");
expect(repeat?.optional).toBe(true);
},
);
});
3 changes: 3 additions & 0 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,9 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push(
'The element itself renders once (as the container), and its children are expanded once per array item. "statePath" is the state array path. "key" is an optional field name on each item for stable React keys.',
);
lines.push(
'For nested lists, an inner repeat can read an array from the enclosing item with { "statePath": { "$item": "field" } }. This form is valid only inside another repeat. Use an empty field to repeat over the enclosing item itself.',
);
lines.push(
`Example: ${JSON.stringify({ type: comp1, props: comp1Props, repeat: { statePath: "/todos", key: "id" }, children: ["todo-item"] })}`,
);
Expand Down
197 changes: 197 additions & 0 deletions packages/core/src/spec-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,203 @@ describe("repeat validation", () => {
});
expect(runtimeState.valid).toBe(true);
});

it("accepts a nested repeat relative to the enclosing item", () => {
const result = validateSpec({
root: "groups",
state: {
groups: [{ subitems: [{ label: "a" }] }],
},
elements: {
groups: {
type: "Stack",
props: {},
repeat: { statePath: "/groups" },
children: ["subitems"],
},
subitems: {
type: "Stack",
props: {},
repeat: { statePath: { $item: "subitems" } },
children: ["label"],
},
label: { type: "Text", props: {}, children: [] },
},
});

expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});

it("rejects a relative repeat outside repeat scope", () => {
const result = validateSpec({
root: "items",
state: { items: [{ label: "root" }] },
elements: {
items: {
type: "Stack",
props: {},
repeat: { statePath: { $item: "items" } },
children: ["label"],
},
label: { type: "Text", props: {}, children: [] },
},
});

expect(result.valid).toBe(false);
expect(
result.issues.some((issue) => issue.code === "repeat_item_outside_scope"),
).toBe(true);
});

it("accepts relative repeat structure when the outer sample array is empty", () => {
const result = validateSpec({
root: "groups",
state: { groups: [] },
elements: {
groups: {
type: "Stack",
props: {},
repeat: { statePath: "/groups" },
children: ["subitems"],
},
subitems: {
type: "Stack",
props: {},
repeat: { statePath: { $item: "subitems" } },
children: ["label"],
},
label: { type: "Text", props: {}, children: [] },
},
});

expect(result.valid).toBe(true);
});

it("rejects a nested relative repeat that does not resolve to an array", () => {
const result = validateSpec({
root: "groups",
state: { groups: [{ subitems: { label: "a" } }] },
elements: {
groups: {
type: "Stack",
props: {},
repeat: { statePath: "/groups" },
children: ["subitems"],
},
subitems: {
type: "Stack",
props: {},
repeat: { statePath: { $item: "/subitems" } },
children: ["label"],
},
label: { type: "Text", props: {}, children: [] },
},
});

expect(result.valid).toBe(false);
expect(
result.issues.some((issue) => issue.code === "repeat_state_mismatch"),
).toBe(true);
});

it("does not duplicate repeat issues for the same structural context", () => {
const result = validateSpec({
root: "root",
state: { items: [] },
elements: {
root: {
type: "Stack",
props: {},
children: ["left", "right"],
},
left: {
type: "Stack",
props: {},
children: ["shared"],
},
right: {
type: "Stack",
props: {},
children: ["shared"],
},
shared: {
type: "Stack",
props: {},
repeat: { statePath: { $item: "items" } },
children: ["label"],
},
label: { type: "Text", props: {}, children: [] },
},
});

expect(
result.issues.filter(
(issue) => issue.code === "repeat_item_outside_scope",
),
).toHaveLength(1);
});

it("validates a reused repeat separately inside and outside scope", () => {
const result = validateSpec({
root: "root",
state: { groups: [{ items: [] }] },
elements: {
root: {
type: "Stack",
props: {},
children: ["groups", "shared"],
},
groups: {
type: "Stack",
props: {},
repeat: { statePath: "/groups" },
children: ["shared"],
},
shared: {
type: "Stack",
props: {},
repeat: { statePath: { $item: "items" } },
children: ["label"],
},
label: { type: "Text", props: {}, children: [] },
},
});

expect(
result.issues.filter(
(issue) => issue.code === "repeat_item_outside_scope",
),
).toHaveLength(1);
expect(
result.issues.filter((issue) => issue.code === "repeat_state_mismatch"),
).toHaveLength(0);
});

it("terminates repeat validation for cyclic child graphs", () => {
const result = validateSpec({
root: "groups",
state: { groups: [{ items: [] }] },
elements: {
groups: {
type: "Stack",
props: {},
repeat: { statePath: "/groups" },
children: ["items"],
},
items: {
type: "Stack",
props: {},
repeat: { statePath: { $item: "items" } },
children: ["groups"],
},
},
});

expect(
result.issues.filter((issue) => issue.code === "repeat_state_mismatch"),
).toHaveLength(0);
});
});

describe("visible condition validation", () => {
Expand Down
Loading
Loading