`). 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";
+
+
}
+/>;
+```
+
+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.
@@ -134,20 +190,18 @@ After the stream ends, check `meta.unresolved` for any identifiers that were ref
`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`:
@@ -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
diff --git a/packages/react-lang/package.json b/packages/react-lang/package.json
index d35f7af57..cb0780e7e 100644
--- a/packages/react-lang/package.json
+++ b/packages/react-lang/package.json
@@ -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"
}
}
diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx
index 0c06a8c93..227612592 100644
--- a/packages/react-lang/src/Renderer.tsx
+++ b/packages/react-lang/src/Renderer.tsx
@@ -43,8 +43,35 @@ export interface RendererProps {
| Record
) => Promise>
| 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";
+ *
+ * ```
+ *
+ * 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,
@@ -208,6 +235,8 @@ export function Renderer({
onParseResult,
toolProvider,
queryLoader,
+ containerComponent,
+ contentComponent,
onError,
}: RendererProps) {
useInsertionEffect(() => {
@@ -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 ? : null);
+
return (
-
- {isQueryLoading && (queryLoader ??
)}
-
+
+ {isQueryLoading && loader}
+
-
-
+
+
);
}
diff --git a/packages/react-lang/src/__tests__/Renderer.test.tsx b/packages/react-lang/src/__tests__/Renderer.test.tsx
new file mode 100644
index 000000000..094359eb3
--- /dev/null
+++ b/packages/react-lang/src/__tests__/Renderer.test.tsx
@@ -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 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 }) =>
{props.value as string},
+});
+
+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
.
+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(
);
+ expect(html).toContain("
{
+ const html = renderToStaticMarkup(
+
,
+ );
+ // The crash-prone web host is gone; native wrappers are mounted instead.
+ expect(html).not.toContain("
{
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).not.toContain("transition");
+ });
+
+ it("renders nothing for a null response regardless of host", () => {
+ expect(renderToStaticMarkup()).toBe("");
+ expect(
+ renderToStaticMarkup(
+ ,
+ ),
+ ).toBe("");
+ });
+});
diff --git a/packages/react-lang/vitest.config.ts b/packages/react-lang/vitest.config.ts
new file mode 100644
index 000000000..f612c07f7
--- /dev/null
+++ b/packages/react-lang/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ exclude: ["dist/**", "node_modules/**"],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cf1b406dd..ceb961e44 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1220,6 +1220,12 @@ importers:
'@types/react':
specifier: ^19.0.0
version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.2.3(@types/react@19.2.14)
+ react-dom:
+ specifier: ^18.3.1 || ^19.0.0
+ version: 19.2.4(react@19.2.4)
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(sass@1.89.2)(terser@5.43.0)(tsx@4.20.3)(yaml@2.8.3)