diff --git a/packages/react-lang/README.md b/packages/react-lang/README.md index 111a12244..a60f22366 100644 --- a/packages/react-lang/README.md +++ b/packages/react-lang/README.md @@ -42,9 +42,7 @@ const Greeting = defineComponent({ mood: z.enum(["happy", "excited"]).optional().describe("Tone of the greeting"), }), component: ({ name, mood }) => ( -
- Hello, {name}! -
+
Hello, {name}!
), }); ``` @@ -91,42 +89,100 @@ 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) => void` | Callback when form field values change | -| `initialState` | `Record` | 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) => void` | Callback when form field values change | +| `initialState` | `Record` | 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 (`
`). 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)