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
152 changes: 103 additions & 49 deletions packages/react-lang/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ const Greeting = defineComponent({
mood: z.enum(["happy", "excited"]).optional().describe("Tone of the greeting"),
}),
component: ({ name, mood }) => (
<div className={mood === "excited" ? "text-xl font-bold" : ""}>
Hello, {name}!
</div>
<div className={mood === "excited" ? "text-xl font-bold" : ""}>Hello, {name}!</div>
),
});
```
Expand Down Expand Up @@ -91,63 +89,119 @@ function AssistantMessage({ response, isStreaming }) {

### Component Definition

| Export | Description |
| :--- | :--- |
| `defineComponent(config)` | Define a single component with a name, Zod props schema, description, and React renderer |
| `createLibrary(definition)` | Create a library from an array of defined components |
| Export | Description |
| :-------------------------- | :--------------------------------------------------------------------------------------- |
| `defineComponent(config)` | Define a single component with a name, Zod props schema, description, and React renderer |
| `createLibrary(definition)` | Create a library from an array of defined components |

### Rendering

| Export | Description |
| :--- | :--- |
| Export | Description |
| :--------- | :--------------------------------------------------------- |
| `Renderer` | React component that parses and renders OpenUI Lang output |

**`RendererProps`:**

| Prop | Type | Description |
| :--- | :--- | :--- |
| `response` | `string \| null` | Raw OpenUI Lang text from the model |
| `library` | `Library` | Component library from `createLibrary()` |
| `isStreaming` | `boolean` | Whether the model is still streaming (disables form interactions) |
| `onAction` | `(event: ActionEvent) => void` | Callback when a component triggers an action |
| `onStateUpdate` | `(state: Record<string, any>) => void` | Callback when form field values change |
| `initialState` | `Record<string, any>` | Initial form state for hydration |
| `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes |
| Prop | Type | Description |
| :------------------- | :-------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------- |
| `response` | `string \| null` | Raw OpenUI Lang text from the model |
| `library` | `Library` | Component library from `createLibrary()` |
| `isStreaming` | `boolean` | Whether the model is still streaming (disables form interactions) |
| `onAction` | `(event: ActionEvent) => void` | Callback when a component triggers an action |
| `onStateUpdate` | `(state: Record<string, any>) => void` | Callback when form field values change |
| `initialState` | `Record<string, any>` | Initial form state for hydration |
| `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes |
| `queryLoader` | `React.ReactNode` | Custom loading indicator shown while queries fetch. Defaults to a DOM spinner on the web; skipped on non-web hosts unless provided |
| `containerComponent` | `React.ElementType` | Host element for the outer wrapper. Defaults to `"div"`. Set to a native host (e.g. `View`) for React Native — see below |
| `contentComponent` | `React.ElementType` | Host element for the inner content wrapper. Defaults to `"div"`. Set to a native host for React Native |

## React Native / Expo

By default the `Renderer` wraps your content in web host elements (`<div>`). In
React Native and Expo, lowercase tag names like `div` are not valid host
components — Metro/React Native interprets them as native component references
and the app crashes with:

```
View config getter callback for component `div` must be a function (received `undefined`)
```

Pass native host components for the two wrappers so the Renderer mounts native
elements instead. The web-only CSS fade transition and the default DOM spinner
are automatically skipped when a non-string host is supplied:

```tsx
import { View } from "react-native";
import { Renderer } from "@openuidev/react-lang";

<Renderer
response={openui}
library={library}
containerComponent={View}
contentComponent={View}
// On native there's no default spinner — supply your own if you want one:
queryLoader={<ActivityIndicator />}
/>;
```

Your component library should also use native primitives (`View`, `Text`,
`Pressable`, …) in each component's renderer rather than web elements.

### Single React copy (pnpm workspaces / Expo)

React hooks require exactly one copy of React at runtime. When you link this
package from a monorepo (e.g. a pnpm workspace consumed by an Expo app), Metro
can end up resolving the library's own development copy of React, producing
`Invalid hook call`. Force every import of `react` and `react-dom` to resolve to
the app's copy in `metro.config.js`:

```js
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");

const config = getDefaultConfig(__dirname);

config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules,
react: path.resolve(__dirname, "node_modules/react"),
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
};

module.exports = config;
```

### Parser (Server-Side)

| Export | Description |
| :--- | :--- |
| `createParser(library)` | Create a one-shot parser for complete OpenUI Lang text |
| `createStreamingParser(library)` | Create an incremental parser for streaming input |
| Export | Description |
| :------------------------------- | :----------------------------------------------------- |
| `createParser(library)` | Create a one-shot parser for complete OpenUI Lang text |
| `createStreamingParser(library)` | Create an incremental parser for streaming input |

The streaming parser exposes two methods:

| Method | Description |
| :--- | :--- |
| Method | Description |
| :------------ | :---------------------------------------------------- |
| `push(chunk)` | Feed the next chunk; returns the latest `ParseResult` |
| `getResult()` | Get the latest result without consuming new data |
| `getResult()` | Get the latest result without consuming new data |

After the stream ends, check `meta.unresolved` for any identifiers that were referenced but never defined. During streaming these are expected (forward refs) and are not treated as errors.

#### Errors

`ParseResult.meta.errors` contains structured `OpenUIError` objects. Each error has a `type` discriminant (currently always `"validation"`) and a `code` for consumer-side filtering:

| Code | Meaning |
| :--- | :--- |
| `missing-required` | Required prop absent with no default |
| `null-required` | Required prop explicitly null with no default |
| `unknown-component` | Component name not found in the library schema |
| `excess-args` | More positional args passed than the schema defines |
| Code | Meaning |
| :------------------ | :-------------------------------------------------- |
| `missing-required` | Required prop absent with no default |
| `null-required` | Required prop explicitly null with no default |
| `unknown-component` | Component name not found in the library schema |
| `excess-args` | More positional args passed than the schema defines |

Errors do not affect rendering. The parser stays permissive and renders what it can. Use `code` to decide how to surface or log errors:

```ts
const result = parser.parse(output);
const critical = result.meta.errors.filter(
(e) => e.code === "unknown-component"
);
const critical = result.meta.errors.filter((e) => e.code === "unknown-component");
```

To check for unresolved references after streaming, inspect `meta.unresolved`:
Expand All @@ -162,24 +216,24 @@ if (result.meta.unresolved.length > 0) {

Use these inside component renderers to interact with the rendering context:

| Hook | Description |
| :--- | :--- |
| `useIsStreaming()` | Whether the model is still streaming |
| `useRenderNode()` | Render child element nodes |
| `useTriggerAction()` | Trigger an action event |
| `useGetFieldValue()` | Get a form field's current value |
| `useSetFieldValue()` | Set a form field's value |
| `useSetDefaultValue()` | Set a field's default value |
| `useFormName()` | Get the current form's name |
| Hook | Description |
| :--------------------- | :----------------------------------- |
| `useIsStreaming()` | Whether the model is still streaming |
| `useRenderNode()` | Render child element nodes |
| `useTriggerAction()` | Trigger an action event |
| `useGetFieldValue()` | Get a form field's current value |
| `useSetFieldValue()` | Set a form field's value |
| `useSetDefaultValue()` | Set a field's default value |
| `useFormName()` | Get the current form's name |

### Form Validation

| Export | Description |
| :--- | :--- |
| `useFormValidation()` | Access form validation state |
| `useCreateFormValidation()` | Create a form validation context |
| `validate(value, rules)` | Run validation rules against a value |
| `builtInValidators` | Built-in validators (required, email, min, max, etc.) |
| Export | Description |
| :-------------------------- | :---------------------------------------------------- |
| `useFormValidation()` | Access form validation state |
| `useCreateFormValidation()` | Create a form validation context |
| `validate(value, rules)` | Run validation rules against a value |
| `builtInValidators` | Built-in validators (required, email, min, max, etc.) |

### Types

Expand Down
2 changes: 2 additions & 0 deletions packages/react-lang/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.27.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0",
"vitest": "^4.0.18"
}
}
59 changes: 53 additions & 6 deletions packages/react-lang/src/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,35 @@ export interface RendererProps {
| Record<string, (args: Record<string, unknown>) => Promise<unknown>>
| McpClientLike
| null;
/** Custom loading indicator shown while queries are fetching. Defaults to a spinner. */
/**
* Custom loading indicator shown while queries are fetching. Defaults to a
* DOM spinner on the web. On non-web hosts (e.g. React Native) the default
* spinner is skipped — pass your own node here to show a loader.
*/
queryLoader?: React.ReactNode;
/**
* Host element used for the Renderer's outer wrapper.
*
* Defaults to `"div"` for the web. On React Native / Expo, lowercase tag
* names like `"div"` are not valid host components and crash with
* "View config getter callback for component div ...". Pass a native host
* (e.g. `View` from `react-native`) so the Renderer mounts native elements:
*
* ```tsx
* import { View } from "react-native";
* <Renderer containerComponent={View} contentComponent={View} ... />
* ```
*
* The component receives a `style` prop, so it must accept one (both `div`
* and `View` do).
*/
containerComponent?: React.ElementType;
/**
* Host element used for the inner content wrapper (the one that fades while
* queries load). Defaults to `"div"`. See {@link containerComponent} for
* React Native usage.
*/
contentComponent?: React.ElementType;
/**
* Called with structured, LLM-friendly errors from the parser and query system.
* Only includes errors fixable by changing the openui-lang code (unknown components,
Expand Down Expand Up @@ -208,6 +235,8 @@ export function Renderer({
onParseResult,
toolProvider,
queryLoader,
containerComponent,
contentComponent,
onError,
}: RendererProps) {
useInsertionEffect(() => {
Expand Down Expand Up @@ -269,14 +298,32 @@ export function Renderer({
return null;
}

const Container = containerComponent ?? "div";
const Content = contentComponent ?? "div";

// Web host elements are referenced by their string tag ("div"); native hosts
// (e.g. React Native's View) are component references. CSS transitions and the
// default DOM spinner are web-only, so only apply them when the wrapper is a
// web host — passing them to a native View would warn or crash.
const isWebContent = typeof Content === "string";
const isWebContainer = typeof Container === "string";

const contentStyle = isWebContent
? { opacity: isQueryLoading ? 0.7 : 1, transition: "opacity 0.2s ease" }
: { opacity: isQueryLoading ? 0.7 : 1 };

// Fall back to the DOM spinner only on web hosts. On native, render nothing
// unless the host supplied its own queryLoader.
const loader = queryLoader ?? (isWebContainer ? <DefaultQueryLoader /> : null);

return (
<OpenUIContext.Provider value={contextValue}>
<div style={{ position: "relative" }}>
{isQueryLoading && (queryLoader ?? <DefaultQueryLoader />)}
<div style={{ opacity: isQueryLoading ? 0.7 : 1, transition: "opacity 0.2s ease" }}>
<Container style={{ position: "relative" }}>
{isQueryLoading && loader}
<Content style={contentStyle}>
<RenderNode node={result.root} />
</div>
</div>
</Content>
</Container>
</OpenUIContext.Provider>
);
}
71 changes: 71 additions & 0 deletions packages/react-lang/src/__tests__/Renderer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { Renderer } from "../Renderer";
import { createLibrary, defineComponent } from "../library";

// A component that emits a non-div host element so wrapper <div>s can be
// asserted independently of the rendered content.
const Text = defineComponent({
name: "Text",
description: "Displays text",
props: z.object({ value: z.string() }),
component: ({ props }) => <span>{props.value as string}</span>,
});

const library = createLibrary({ components: [Text], root: "Text" });
const RESPONSE = 'root = Text("hello world")';

// A stand-in for React Native's `View`: a host wrapper passed as a component
// reference rather than a string tag. Uses createElement with a custom tag so
// the emitted element is trivially distinguishable from a web <div>.
function NativeView({ children }: { children?: React.ReactNode; style?: unknown }) {
return React.createElement("rn-view", null, children);
}

describe("Renderer host wrappers", () => {
it("defaults to div wrappers on the web", () => {
const html = renderToStaticMarkup(<Renderer response={RESPONSE} library={library} />);
expect(html).toContain("<div");
// Web content wrapper keeps the CSS fade transition.
expect(html).toContain("transition");
expect(html).toContain("hello world");
});

it("uses custom container/content components instead of div (React Native)", () => {
const html = renderToStaticMarkup(
<Renderer
response={RESPONSE}
library={library}
containerComponent={NativeView}
contentComponent={NativeView}
/>,
);
// The crash-prone web host is gone; native wrappers are mounted instead.
expect(html).not.toContain("<div");
expect(html).toContain("<rn-view");
expect(html).toContain("hello world");
});

it("omits the web-only CSS transition when the content host is native", () => {
const html = renderToStaticMarkup(
<Renderer
response={RESPONSE}
library={library}
containerComponent={NativeView}
contentComponent={NativeView}
/>,
);
expect(html).not.toContain("transition");
});

it("renders nothing for a null response regardless of host", () => {
expect(renderToStaticMarkup(<Renderer response={null} library={library} />)).toBe("");
expect(
renderToStaticMarkup(
<Renderer response={null} library={library} containerComponent={NativeView} />,
),
).toBe("");
});
});
7 changes: 7 additions & 0 deletions packages/react-lang/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
exclude: ["dist/**", "node_modules/**"],
},
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.