Skip to content

Commit 9f58a3c

Browse files
Raillytmchow
andauthored
feat(core): support nested repeats with item paths (#319)
Resolve nested repeat state paths consistently across every renderer, validator, schema, prompt, and documentation surface. Fixes #252 Co-authored-by: Trevin Chow <trevin@trevinchow.com>
1 parent 9d3dfc8 commit 9f58a3c

51 files changed

Lines changed: 784 additions & 71 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/app/(main)/docs/api/core/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -650,7 +650,7 @@ interface UIElement {
650650
children?: string[]; // Keys of child elements
651651
visible?: VisibilityCondition;
652652
on?: Record<string, ActionBinding | ActionBinding[]>; // Event bindings
653-
repeat?: { statePath: string; key?: string }; // Repeat for arrays
653+
repeat?: { statePath: string | { $item: string }; key?: string }; // Repeat for arrays
654654
}
655655
```
656656

apps/web/app/(main)/docs/data-binding/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ The `repeat` field on an element renders its children once per item in a state a
126126
}
127127
```
128128

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

132132
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.

packages/core/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,7 @@ console.log(formatSpecIssues(issues));
556556
const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec);
557557
```
558558

559-
`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`).
559+
`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`).
560560

561561
`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:
562562

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type {
44
DynamicString,
55
DynamicNumber,
66
DynamicBoolean,
7+
RepeatStatePath,
78
UIElement,
89
FlatElement,
910
Spec,
@@ -38,6 +39,8 @@ export {
3839
DynamicBooleanSchema,
3940
resolveDynamicValue,
4041
getByPath,
42+
resolveRepeatStatePath,
43+
resolveRepeatItemStatePath,
4144
setByPath,
4245
addByPath,
4346
removeByPath,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { Schema, SchemaType } from "./schema";
3+
import { schema as imageSchema } from "../../image/src/schema";
4+
import { schema as inkSchema } from "../../ink/src/schema";
5+
import { schema as reactEmailSchema } from "../../react-email/src/schema";
6+
import { schema as reactNativeSchema } from "../../react-native/src/schema";
7+
import { schema as reactPdfSchema } from "../../react-pdf/src/schema";
8+
import { schema as reactSchema } from "../../react/src/schema";
9+
import { schema as solidSchema } from "../../solid/src/schema";
10+
import { schema as svelteSchema } from "../../svelte/src/schema";
11+
import { schema as vueSchema } from "../../vue/src/schema";
12+
13+
const schemas: Record<string, Schema> = {
14+
image: imageSchema,
15+
ink: inkSchema,
16+
react: reactSchema,
17+
"react-email": reactEmailSchema,
18+
"react-native": reactNativeSchema,
19+
"react-pdf": reactPdfSchema,
20+
solid: solidSchema,
21+
svelte: svelteSchema,
22+
vue: vueSchema,
23+
};
24+
25+
describe("renderer schema repeat parity", () => {
26+
it.each(Object.entries(schemas))(
27+
"%s declares repeat as an optional element field",
28+
(_name, schema) => {
29+
const spec = schema.definition.spec as SchemaType<
30+
"object",
31+
Record<string, SchemaType>
32+
>;
33+
const elements = spec.inner?.elements as SchemaType<
34+
"record",
35+
SchemaType<"object", Record<string, SchemaType>>
36+
>;
37+
const repeat = elements.inner?.inner?.repeat;
38+
39+
expect(repeat?.kind).toBe("any");
40+
expect(repeat?.optional).toBe(true);
41+
},
42+
);
43+
});

packages/core/src/schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,9 @@ Note: state patches appear right after the elements that use them, so the UI fil
762762
lines.push(
763763
'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.',
764764
);
765+
lines.push(
766+
'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.',
767+
);
765768
lines.push(
766769
`Example: ${JSON.stringify({ type: comp1, props: comp1Props, repeat: { statePath: "/todos", key: "id" }, children: ["todo-item"] })}`,
767770
);

packages/core/src/spec-validator.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,203 @@ describe("repeat validation", () => {
236236
});
237237
expect(runtimeState.valid).toBe(true);
238238
});
239+
240+
it("accepts a nested repeat relative to the enclosing item", () => {
241+
const result = validateSpec({
242+
root: "groups",
243+
state: {
244+
groups: [{ subitems: [{ label: "a" }] }],
245+
},
246+
elements: {
247+
groups: {
248+
type: "Stack",
249+
props: {},
250+
repeat: { statePath: "/groups" },
251+
children: ["subitems"],
252+
},
253+
subitems: {
254+
type: "Stack",
255+
props: {},
256+
repeat: { statePath: { $item: "subitems" } },
257+
children: ["label"],
258+
},
259+
label: { type: "Text", props: {}, children: [] },
260+
},
261+
});
262+
263+
expect(result.valid).toBe(true);
264+
expect(result.issues).toHaveLength(0);
265+
});
266+
267+
it("rejects a relative repeat outside repeat scope", () => {
268+
const result = validateSpec({
269+
root: "items",
270+
state: { items: [{ label: "root" }] },
271+
elements: {
272+
items: {
273+
type: "Stack",
274+
props: {},
275+
repeat: { statePath: { $item: "items" } },
276+
children: ["label"],
277+
},
278+
label: { type: "Text", props: {}, children: [] },
279+
},
280+
});
281+
282+
expect(result.valid).toBe(false);
283+
expect(
284+
result.issues.some((issue) => issue.code === "repeat_item_outside_scope"),
285+
).toBe(true);
286+
});
287+
288+
it("accepts relative repeat structure when the outer sample array is empty", () => {
289+
const result = validateSpec({
290+
root: "groups",
291+
state: { groups: [] },
292+
elements: {
293+
groups: {
294+
type: "Stack",
295+
props: {},
296+
repeat: { statePath: "/groups" },
297+
children: ["subitems"],
298+
},
299+
subitems: {
300+
type: "Stack",
301+
props: {},
302+
repeat: { statePath: { $item: "subitems" } },
303+
children: ["label"],
304+
},
305+
label: { type: "Text", props: {}, children: [] },
306+
},
307+
});
308+
309+
expect(result.valid).toBe(true);
310+
});
311+
312+
it("rejects a nested relative repeat that does not resolve to an array", () => {
313+
const result = validateSpec({
314+
root: "groups",
315+
state: { groups: [{ subitems: { label: "a" } }] },
316+
elements: {
317+
groups: {
318+
type: "Stack",
319+
props: {},
320+
repeat: { statePath: "/groups" },
321+
children: ["subitems"],
322+
},
323+
subitems: {
324+
type: "Stack",
325+
props: {},
326+
repeat: { statePath: { $item: "/subitems" } },
327+
children: ["label"],
328+
},
329+
label: { type: "Text", props: {}, children: [] },
330+
},
331+
});
332+
333+
expect(result.valid).toBe(false);
334+
expect(
335+
result.issues.some((issue) => issue.code === "repeat_state_mismatch"),
336+
).toBe(true);
337+
});
338+
339+
it("does not duplicate repeat issues for the same structural context", () => {
340+
const result = validateSpec({
341+
root: "root",
342+
state: { items: [] },
343+
elements: {
344+
root: {
345+
type: "Stack",
346+
props: {},
347+
children: ["left", "right"],
348+
},
349+
left: {
350+
type: "Stack",
351+
props: {},
352+
children: ["shared"],
353+
},
354+
right: {
355+
type: "Stack",
356+
props: {},
357+
children: ["shared"],
358+
},
359+
shared: {
360+
type: "Stack",
361+
props: {},
362+
repeat: { statePath: { $item: "items" } },
363+
children: ["label"],
364+
},
365+
label: { type: "Text", props: {}, children: [] },
366+
},
367+
});
368+
369+
expect(
370+
result.issues.filter(
371+
(issue) => issue.code === "repeat_item_outside_scope",
372+
),
373+
).toHaveLength(1);
374+
});
375+
376+
it("validates a reused repeat separately inside and outside scope", () => {
377+
const result = validateSpec({
378+
root: "root",
379+
state: { groups: [{ items: [] }] },
380+
elements: {
381+
root: {
382+
type: "Stack",
383+
props: {},
384+
children: ["groups", "shared"],
385+
},
386+
groups: {
387+
type: "Stack",
388+
props: {},
389+
repeat: { statePath: "/groups" },
390+
children: ["shared"],
391+
},
392+
shared: {
393+
type: "Stack",
394+
props: {},
395+
repeat: { statePath: { $item: "items" } },
396+
children: ["label"],
397+
},
398+
label: { type: "Text", props: {}, children: [] },
399+
},
400+
});
401+
402+
expect(
403+
result.issues.filter(
404+
(issue) => issue.code === "repeat_item_outside_scope",
405+
),
406+
).toHaveLength(1);
407+
expect(
408+
result.issues.filter((issue) => issue.code === "repeat_state_mismatch"),
409+
).toHaveLength(0);
410+
});
411+
412+
it("terminates repeat validation for cyclic child graphs", () => {
413+
const result = validateSpec({
414+
root: "groups",
415+
state: { groups: [{ items: [] }] },
416+
elements: {
417+
groups: {
418+
type: "Stack",
419+
props: {},
420+
repeat: { statePath: "/groups" },
421+
children: ["items"],
422+
},
423+
items: {
424+
type: "Stack",
425+
props: {},
426+
repeat: { statePath: { $item: "items" } },
427+
children: ["groups"],
428+
},
429+
},
430+
});
431+
432+
expect(
433+
result.issues.filter((issue) => issue.code === "repeat_state_mismatch"),
434+
).toHaveLength(0);
435+
});
239436
});
240437

241438
describe("visible condition validation", () => {

0 commit comments

Comments
 (0)