diff --git a/packages/lang-core/src/runtime/__tests__/evaluate-tree.test.ts b/packages/lang-core/src/runtime/__tests__/evaluate-tree.test.ts new file mode 100644 index 000000000..382131542 --- /dev/null +++ b/packages/lang-core/src/runtime/__tests__/evaluate-tree.test.ts @@ -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'); + }); +}); diff --git a/packages/lang-core/src/runtime/evaluate-tree.ts b/packages/lang-core/src/runtime/evaluate-tree.ts index ded4de75d..799e1a988 100644 --- a/packages/lang-core/src/runtime/evaluate-tree.ts +++ b/packages/lang-core/src/runtime/evaluate-tree.ts @@ -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"; @@ -25,6 +26,71 @@ export interface EvalContext { errors?: OpenUIError[]; } +type SafeParseResult = + | { success: true; data: unknown } + | { + success: false; + error?: { issues?: Array<{ message: string; path?: Array }> }; + }; + +type SafeParseSchema = { + safeParse?: (value: unknown) => SafeParseResult; +}; + +function containsElementNode(value: unknown, seen = new WeakSet()): 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).some((item) => + containsElementNode(item, seen), + ); +} + +function formatZodIssues( + propName: string, + issues: Array<{ message: string; path?: Array }> | 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. @@ -32,8 +98,6 @@ export interface EvalContext { * 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 = {}; @@ -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; diff --git a/packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx b/packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx index 6bccd43ff..ba9f143c8 100644 --- a/packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx +++ b/packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx @@ -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({ @@ -19,7 +20,7 @@ export const FollowUpBlock = defineComponent({ return ( {items.map((item, i) => { - const text = String(item?.props?.text ?? ""); + const text = displayText(item?.props?.text); return triggerAction(text)} />; })} diff --git a/packages/react-ui/src/genui-lib/ListBlock/index.tsx b/packages/react-ui/src/genui-lib/ListBlock/index.tsx index e24b33fdd..37c8cbfa9 100644 --- a/packages/react-ui/src/genui-lib/ListBlock/index.tsx +++ b/packages/react-ui/src/genui-lib/ListBlock/index.tsx @@ -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({ @@ -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 ( {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; diff --git a/packages/react-ui/src/genui-lib/SectionBlock/index.tsx b/packages/react-ui/src/genui-lib/SectionBlock/index.tsx index e38f22e06..6cf5e2a1c 100644 --- a/packages/react-ui/src/genui-lib/SectionBlock/index.tsx +++ b/packages/react-ui/src/genui-lib/SectionBlock/index.tsx @@ -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({ @@ -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([]); const userSelected = React.useRef(false); @@ -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])); } @@ -66,7 +67,7 @@ export const SectionBlock = defineComponent({ return ( <> {items.map((item, index) => ( - + {renderNode(item?.props?.content)} ))} @@ -77,8 +78,11 @@ export const SectionBlock = defineComponent({ return ( {items.map((item, index) => ( - - + + {renderNode(item?.props?.content)} ))} diff --git a/packages/react-ui/src/genui-lib/TextContent/index.tsx b/packages/react-ui/src/genui-lib/TextContent/index.tsx index cfbf2683e..fa6014b17 100644 --- a/packages/react-ui/src/genui-lib/TextContent/index.tsx +++ b/packages/react-ui/src/genui-lib/TextContent/index.tsx @@ -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 = { small: "--openui-text-body-sm", @@ -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 (
diff --git a/packages/react-ui/src/genui-lib/__tests__/helpers.test.ts b/packages/react-ui/src/genui-lib/__tests__/helpers.test.ts new file mode 100644 index 000000000..53558eed9 --- /dev/null +++ b/packages/react-ui/src/genui-lib/__tests__/helpers.test.ts @@ -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 = {}; + 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"); + }); +}); diff --git a/packages/react-ui/src/genui-lib/helpers.ts b/packages/react-ui/src/genui-lib/helpers.ts index 767cbf0a4..f5b5f4c5f 100644 --- a/packages/react-ui/src/genui-lib/helpers.ts +++ b/packages/react-ui/src/genui-lib/helpers.ts @@ -3,6 +3,71 @@ type ElementLike = { props: Record; }; +const DISPLAY_TEXT_KEYS = ["title", "text", "label", "value"] as const; + +export interface DisplayTextCoercionResult { + text: string; + coerced: boolean; + sourceType: string; + fallbackKey?: (typeof DISPLAY_TEXT_KEYS)[number]; +} + +function valueType(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function stringifyObject(value: object): string { + try { + return JSON.stringify(value) ?? ""; + } catch { + return ""; + } +} + +export function coerceDisplayText(value: unknown): DisplayTextCoercionResult { + if (value == null) { + return { text: "", coerced: false, sourceType: valueType(value) }; + } + + if (typeof value === "string") { + return { text: value, coerced: false, sourceType: "string" }; + } + + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return { text: String(value), coerced: true, sourceType: valueType(value) }; + } + + if (Array.isArray(value)) { + const parts = value.map((item) => coerceDisplayText(item).text).filter(Boolean); + return { text: parts.join(", "), coerced: true, sourceType: "array" }; + } + + if (typeof value === "object") { + const obj = value as Record; + for (const key of DISPLAY_TEXT_KEYS) { + if (!(key in obj)) continue; + const result = coerceDisplayText(obj[key]); + if (result.text) { + return { text: result.text, coerced: true, sourceType: "object", fallbackKey: key }; + } + } + return { text: stringifyObject(value), coerced: true, sourceType: "object" }; + } + + return { text: "", coerced: true, sourceType: valueType(value) }; +} + +export function displayText(value: unknown): string { + return coerceDisplayText(value).text; +} + +export function optionalDisplayText(value: unknown): string | undefined { + const text = displayText(value); + return text ? text : undefined; +} + export function hasAllProps(obj: Record, ...keys: string[]): boolean { return keys.every((k) => obj[k] != null); }