Skip to content
Open
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
77 changes: 77 additions & 0 deletions packages/lang-core/src/runtime/__tests__/evaluate-tree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { createLibrary, defineComponent } from "../../library";
import type { ElementNode, OpenUIError } from "../../parser/types";
import { evaluateElementProps } from "../evaluate-tree";

const ListItem = defineComponent({
name: "ListItem",
props: z.object({
title: z.string(),
subtitle: z.string().optional(),
}),
description: "List item",
component: () => null,
});

const ListBlock = defineComponent({
name: "ListBlock",
props: z.object({
items: z.array(ListItem.ref),
}),
description: "List block",
component: () => null,
});

const library = createLibrary({
components: [ListBlock, ListItem],
});

const evalContext = {
getState: () => undefined,
resolveRef: () => undefined,
};

describe("evaluateElementProps", () => {
it("reports schema mismatches on static nested component props", () => {
const errors: OpenUIError[] = [];
const root: ElementNode = {
type: "element",
typeName: "ListBlock",
props: {
items: [
{
type: "element",
typeName: "ListItem",
statementId: "item1",
props: {
title: { title: "Inbox", subtitle: "12 unread" },
},
partial: false,
hasDynamicProps: false,
},
],
},
partial: false,
hasDynamicProps: false,
};

const evaluated = evaluateElementProps(root, {
ctx: evalContext,
library,
store: null,
errors,
});

expect(evaluated).toEqual(root);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({
source: "runtime",
code: "runtime-error",
component: "ListItem",
statementId: "item1",
path: "/title",
});
expect(errors[0]?.message).toContain('Prop "title" on ListItem does not match its schema');
});
});
76 changes: 74 additions & 2 deletions packages/lang-core/src/runtime/evaluate-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import type { Library } from "../library";
import type { ElementNode, OpenUIError } from "../parser/types";
import { isElementNode } from "../parser/types";
import { evaluatePropCore } from "./evaluate-prop";
import type { EvaluationContext, SchemaContext } from "./evaluator";
import type { Store } from "./store";
Expand All @@ -25,15 +26,78 @@ export interface EvalContext {
errors?: OpenUIError[];
}

type SafeParseResult =
| { success: true; data: unknown }
| {
success: false;
error?: { issues?: Array<{ message: string; path?: Array<string | number> }> };
};

type SafeParseSchema = {
safeParse?: (value: unknown) => SafeParseResult;
};

function containsElementNode(value: unknown, seen = new WeakSet<object>()): boolean {
if (isElementNode(value)) return true;
if (!value || typeof value !== "object") return false;
if (seen.has(value)) return false;
seen.add(value);
if (Array.isArray(value)) return value.some((item) => containsElementNode(item, seen));
return Object.values(value as Record<string, unknown>).some((item) =>
containsElementNode(item, seen),
);
}

function formatZodIssues(
propName: string,
issues: Array<{ message: string; path?: Array<string | number> }> | undefined,
): string {
if (!issues?.length) return `/${propName}: invalid value`;
return issues
.map((issue) => {
const suffix = issue.path?.length ? `/${issue.path.join("/")}` : "";
return `/${propName}${suffix}: ${issue.message}`;
})
.join("; ");
}

function validateEvaluatedProp(
component: string,
propName: string,
propSchema: unknown,
value: unknown,
statementId: string | undefined,
errors: OpenUIError[] | undefined,
): void {
if (!errors || containsElementNode(value)) return;

const safeParse = (propSchema as SafeParseSchema | undefined)?.safeParse;
if (typeof safeParse !== "function") return;

const result = safeParse.call(propSchema, value);
if (result.success) return;

errors.push({
source: "runtime",
code: "runtime-error",
component,
statementId,
path: `/${propName}`,
message: `Prop "${propName}" on ${component} does not match its schema: ${formatZodIssues(
propName,
result.error?.issues,
)}`,
hint: `Use a value that matches the declared schema for ${component}.${propName}.`,
});
}

/**
* Evaluate all AST nodes in an ElementNode tree's props.
* Returns a new ElementNode with all props resolved to concrete values.
*
* Uses the unified evaluator with schema context for reactive-aware evaluation.
*/
export function evaluateElementProps(el: ElementNode, evalCtx: EvalContext): ElementNode {
if (el.hasDynamicProps === false) return el;

const schemaCtx: SchemaContext = { library: evalCtx.library };
const def = evalCtx.library.components[el.typeName];
const evaluated: Record<string, unknown> = {};
Expand All @@ -42,6 +106,14 @@ export function evaluateElementProps(el: ElementNode, evalCtx: EvalContext): Ele
const propSchema = def?.props?.shape?.[key];
try {
evaluated[key] = evaluatePropValue(value, evalCtx, schemaCtx, propSchema);
validateEvaluatedProp(
el.typeName,
key,
propSchema,
evaluated[key],
el.statementId,
evalCtx.errors,
);
} catch (e) {
// Use raw value as fallback for this prop, collect structured error
evaluated[key] = value;
Expand Down
3 changes: 2 additions & 1 deletion packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defineComponent, useTriggerAction } from "@openuidev/react-lang";
import { z } from "zod/v4";
import { FollowUpBlock as OpenUIFollowUpBlock } from "../../components/FollowUpBlock";
import { FollowUpItem as OpenUIFollowUpItem } from "../../components/FollowUpItem";
import { displayText } from "../helpers";
import { FollowUpItem } from "../FollowUpItem";

export const FollowUpBlock = defineComponent({
Expand All @@ -19,7 +20,7 @@ export const FollowUpBlock = defineComponent({
return (
<OpenUIFollowUpBlock>
{items.map((item, i) => {
const text = String(item?.props?.text ?? "");
const text = displayText(item?.props?.text);
return <OpenUIFollowUpItem key={i} text={text} onClick={() => triggerAction(text)} />;
})}
</OpenUIFollowUpBlock>
Expand Down
9 changes: 5 additions & 4 deletions packages/react-ui/src/genui-lib/ListBlock/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ChevronRight } from "lucide-react";
import { z } from "zod/v4";
import { ListBlock as OpenUIListBlock } from "../../components/ListBlock";
import { ListItem as OpenUIListItem } from "../../components/ListItem";
import { displayText, optionalDisplayText } from "../helpers";
import { ListItem } from "../ListItem";

export const ListBlock = defineComponent({
Expand All @@ -20,15 +21,15 @@ export const ListBlock = defineComponent({
const triggerAction = useTriggerAction();
const items = (props.items ?? []) as any[];
const variant = (props.variant as "number" | "image") ?? "number";
const listHasSubtitle = items.some((item) => !!item?.props?.subtitle);
const listHasSubtitle = items.some((item) => !!optionalDisplayText(item?.props?.subtitle));

return (
<OpenUIListBlock variant={variant}>
{items.map((item, index) => {
const title = String(item?.props?.title ?? "");
const subtitle = item?.props?.subtitle ? String(item.props.subtitle) : undefined;
const title = displayText(item?.props?.title);
const subtitle = optionalDisplayText(item?.props?.subtitle);
const image = item?.props?.image as { src: string; alt: string } | undefined;
const actionLabel = item?.props?.actionLabel ? String(item.props.actionLabel) : undefined;
const actionLabel = optionalDisplayText(item?.props?.actionLabel);
const action = item?.props?.action;
const hasAction = !!action;

Expand Down
14 changes: 9 additions & 5 deletions packages/react-ui/src/genui-lib/SectionBlock/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
FoldableSectionTrigger,
} from "../../components/SectionBlock/FoldableSection";
import { SectionV2 } from "../../components/SectionBlock/SectionV2";
import { displayText, optionalDisplayText } from "../helpers";
import { SectionItem } from "../SectionItem";

export const SectionBlock = defineComponent({
Expand All @@ -25,7 +26,7 @@ export const SectionBlock = defineComponent({
const isFoldable = props.isFoldable !== false;
const isStreaming = useIsStreaming();

const firstItemValue = items[0]?.props?.value as string | undefined;
const firstItemValue = optionalDisplayText(items[0]?.props?.value);

const [openItems, setOpenItems] = React.useState<string[]>([]);
const userSelected = React.useRef(false);
Expand All @@ -38,7 +39,7 @@ export const SectionBlock = defineComponent({

if (isStreaming && items.length > prevLengthRef.current && !userSelected.current) {
const last = items[items.length - 1];
const lastValue = last?.props?.value as string | undefined;
const lastValue = optionalDisplayText(last?.props?.value);
if (lastValue) {
setOpenItems((prev) => (prev.includes(lastValue) ? prev : [...prev, lastValue]));
}
Expand Down Expand Up @@ -66,7 +67,7 @@ export const SectionBlock = defineComponent({
return (
<>
{items.map((item, index) => (
<SectionV2 key={index} trigger={String(item?.props?.trigger ?? "")}>
<SectionV2 key={index} trigger={displayText(item?.props?.trigger)}>
{renderNode(item?.props?.content)}
</SectionV2>
))}
Expand All @@ -77,8 +78,11 @@ export const SectionBlock = defineComponent({
return (
<FoldableSectionRoot type="multiple" value={openItems} onValueChange={handleValueChange}>
{items.map((item, index) => (
<FoldableSectionItem key={index} value={String(item?.props?.value ?? index)}>
<FoldableSectionTrigger text={String(item?.props?.trigger ?? "")} />
<FoldableSectionItem
key={index}
value={optionalDisplayText(item?.props?.value) ?? String(index)}
>
<FoldableSectionTrigger text={displayText(item?.props?.trigger)} />
<FoldableSectionContent>{renderNode(item?.props?.content)}</FoldableSectionContent>
</FoldableSectionItem>
))}
Expand Down
3 changes: 2 additions & 1 deletion packages/react-ui/src/genui-lib/TextContent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { defineComponent } from "@openuidev/react-lang";
import React from "react";
import { MarkDownRenderer } from "../../components/MarkDownRenderer";
import { displayText } from "../helpers";
import { TextContentSchema } from "./schema";
const BODY_SIZE_VARS: Record<string, string> = {
small: "--openui-text-body-sm",
Expand All @@ -29,7 +30,7 @@ export const TextContent = defineComponent({
"--openui-text-body-default": `var(${varName})`,
"--openui-text-body-default-letter-spacing": `var(${varName}-letter-spacing)`,
} as React.CSSProperties);
const text = props.text == null ? "" : String(props.text);
const text = displayText(props.text);
return (
<div style={style}>
<MarkDownRenderer textMarkdown={text} />
Expand Down
53 changes: 53 additions & 0 deletions packages/react-ui/src/genui-lib/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { coerceDisplayText, displayText, optionalDisplayText } from "../helpers";

describe("display text coercion", () => {
it("leaves strings unchanged", () => {
expect(coerceDisplayText("Inbox")).toEqual({
text: "Inbox",
coerced: false,
sourceType: "string",
});
});

it("converts primitive non-string values without exposing object strings", () => {
expect(displayText(42)).toBe("42");
expect(displayText(false)).toBe("false");
expect(displayText(null)).toBe("");
expect(displayText(undefined)).toBe("");
});

it("uses object display fallback keys in priority order", () => {
expect(coerceDisplayText({ title: "Inbox", text: "Ignored" })).toMatchObject({
text: "Inbox",
coerced: true,
sourceType: "object",
fallbackKey: "title",
});
expect(displayText({ text: "Follow up" })).toBe("Follow up");
expect(displayText({ label: "Priority" })).toBe("Priority");
expect(displayText({ value: 7 })).toBe("7");
});

it("joins array values using each item's display text", () => {
expect(displayText(["Inbox", { title: "Archive" }, 3, null])).toBe("Inbox, Archive, 3");
});

it("falls back to JSON for unknown object shapes", () => {
const text = displayText({ foo: "bar" });
expect(text).toBe('{"foo":"bar"}');
expect(text).not.toBe("[object Object]");
});

it("does not leak [object Object] for circular objects", () => {
const value: Record<string, unknown> = {};
value.self = value;
expect(displayText(value)).toBe("");
});

it("returns undefined for optional empty display text", () => {
expect(optionalDisplayText(null)).toBeUndefined();
expect(optionalDisplayText("")).toBeUndefined();
expect(optionalDisplayText({ title: "Inbox" })).toBe("Inbox");
});
});
Loading