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
18 changes: 18 additions & 0 deletions docs/content/docs/api-reference/react-lang.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ interface RendererProps {
response: string | null;
library: Library;
isStreaming?: boolean;
revealRate?: boolean | { targetMs?: number; minMsPerChar?: number; minTotalMs?: number };
onAction?: (event: ActionEvent) => void;
onStateUpdate?: (state: Record<string, unknown>) => void;
initialState?: Record<string, any>;
Expand All @@ -111,6 +112,23 @@ interface RendererProps {
}
```

## Reveal pacing

The Renderer renders progressively as the response arrives, so the arrival rate is the reveal pace. When the response arrives bursty or whole — a fast tool-call generation, or a transport that coalesces writes — the components pop in all at once instead of assembling.

`revealRate` decouples paint from arrival: while `isStreaming`, the Renderer feeds the streaming parser a growing prefix of the response on a clock, so completed statements stay stable while the pending one fills in, and the UI assembles the way it would over a smooth stream.

```tsx
<Renderer response={dsl} isStreaming revealRate />
<Renderer response={dsl} isStreaming revealRate={{ targetMs: 1100 }} />
```

- `true` — enable with the defaults (`targetMs: 1000`, `minMsPerChar: 1.2`, `minTotalMs: 400`).
- object — enable, overriding individual knobs.
- `false` / omitted — no pacing (render as the response arrives, the default).

Settled content (not streaming, e.g. hydrated on load) and `prefers-reduced-motion` render immediately — no fake assembly over stable content.

## Tool Provider

Handles `Query()` and `Mutation()` tool calls at runtime. The `toolProvider` prop accepts two forms:
Expand Down
18 changes: 17 additions & 1 deletion packages/react-lang/src/Renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { ToolNotFoundError, extractToolResult } from "@openuidev/lang-core";
import React, { Component, Fragment, useEffect, useInsertionEffect, useRef } from "react";
import { OpenUIContext, useOpenUI, useRenderNode } from "./context";
import { useOpenUIState } from "./hooks/useOpenUIState";
import { useRevealedResponse } from "./hooks/useRevealedResponse";
import type { ComponentRenderer, Library } from "./library";
import type { RevealRate } from "./reveal";

export interface RendererProps {
/** Raw response text (openui-lang code). */
Expand All @@ -19,6 +21,15 @@ export interface RendererProps {
library: Library;
/** Whether the LLM is still streaming (form interactions disabled during streaming). */
isStreaming?: boolean;
/**
* Pace the reveal of a streamed response so components assemble instead of
* popping in when the response arrives bursty or whole (a fast generation, or a
* transport that coalesces writes). `true` uses the defaults; an object tunes
* the timing; `false`/omitted keeps the current behavior (render as it arrives).
* Only active while `isStreaming`; settled content and prefers-reduced-motion
* render immediately. See RevealRateOptions.
*/
revealRate?: RevealRate;
/** Callback when a component triggers an action. */
onAction?: (event: ActionEvent) => void;
/**
Expand Down Expand Up @@ -202,6 +213,7 @@ export function Renderer({
response,
library,
isStreaming = false,
revealRate,
onAction,
onStateUpdate,
initialState,
Expand All @@ -214,6 +226,10 @@ export function Renderer({
ensureLoadingStyle();
}, []);

// Pace the reveal so components assemble instead of popping in on a bursty
// response. No-op unless revealRate is set; feeds the parser a growing prefix.
const revealedResponse = useRevealedResponse(response, isStreaming, revealRate);

const onParseResultRef = useRef(onParseResult);
onParseResultRef.current = onParseResult;

Expand Down Expand Up @@ -247,7 +263,7 @@ export function Renderer({

const { result, parseResult, contextValue, isQueryLoading } = useOpenUIState(
{
response,
response: revealedResponse,
library,
isStreaming,
onAction,
Expand Down
97 changes: 97 additions & 0 deletions packages/react-lang/src/__tests__/reveal-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createStreamingParser } from "@openuidev/lang-core";
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { createLibrary, defineComponent } from "../library";
import { DEFAULT_REVEAL_RATE, revealDurationMs, revealedCountAt } from "../reveal";

// Components never render in a parser-level test — a trivial FC satisfies the type.
const Dummy = () => null;

const Section = defineComponent({
name: "Section",
props: z.object({ title: z.string(), children: z.array(z.any()) }),
description: "A section with children",
component: Dummy,
});
const Card = defineComponent({
name: "Card",
props: z.object({ label: z.string() }),
description: "A card",
component: Dummy,
});
const library = createLibrary({ components: [Section, Card], root: "Section" });
const schema = library.toJSONSchema();

// Ref-list-first DSL: the root line lists its children, so appending a child
// rewrites the root line — the string is NON-monotonic, the exact shape a naive
// prefix reveal blanks-and-rebuilds on.
const DSL = [
'root = Section("Weekly review", [b0, b1])',
'b0 = Card("Invoices")',
'b1 = Card("Alerts")',
].join("\n");

function fullParse() {
return createStreamingParser(schema, "Section").set(DSL);
}

describe("revealRate mechanism — a clock-paced prefix drives real assembly", () => {
it("a full parse resolves the whole tree", () => {
const full = fullParse();
expect(full.root).not.toBeNull();
expect(full.meta.incomplete).toBe(false);
expect(full.meta.unresolved).toEqual([]);
expect(full.meta.statementCount).toBe(3); // root + b0 + b1
});

it("reveals nothing at elapsed 0", () => {
const sp = createStreamingParser(schema, "Section");
const count = revealedCountAt(
0,
DSL.length,
0,
revealDurationMs(DSL.length, DEFAULT_REVEAL_RATE),
);
expect(count).toBe(0);
expect(sp.set(DSL.slice(0, count)).root).toBeNull();
});

it("feeding the real parser clock-paced growing prefixes assembles the UI and never blanks", () => {
const finalStmt = fullParse().meta.statementCount;
const sp = createStreamingParser(schema, "Section");
const durationMs = revealDurationMs(DSL.length, DEFAULT_REVEAL_RATE);

let prevStmt = -1;
let sawRoot = false;
let sawPartialAssembly = false; // root present while the tree is still filling in

const STEPS = 60;
for (let i = 0; i <= STEPS; i++) {
const elapsed = (durationMs * i) / STEPS;
const count = revealedCountAt(0, DSL.length, elapsed, durationMs);
const r = sp.set(DSL.slice(0, count));

// Never blanks: the completed-statement count is monotonic non-decreasing
// across the whole reveal — a completed component is never un-rendered.
expect(r.meta.statementCount).toBeGreaterThanOrEqual(prevStmt);
prevStmt = r.meta.statementCount;

if (r.root) {
sawRoot = true;
if (r.meta.statementCount < finalStmt) sawPartialAssembly = true;
}
}

// The root rendered mid-stream, before every statement had completed — i.e.
// the components genuinely ASSEMBLED rather than popping in only at the end.
expect(sawRoot).toBe(true);
expect(sawPartialAssembly).toBe(true);

// And the paced stream lands exactly where a fresh full parse does.
const final = sp.set(DSL);
expect(final.root).not.toBeNull();
expect(final.meta.incomplete).toBe(false);
expect(final.meta.unresolved).toEqual([]);
expect(final.meta.statementCount).toBe(finalStmt);
});
});
112 changes: 112 additions & 0 deletions packages/react-lang/src/__tests__/reveal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_REVEAL_RATE,
resolveRevealRate,
revealDurationMs,
revealedCountAt,
} from "../reveal";

// ── resolveRevealRate ────────────────────────────────────────────────────────

describe("resolveRevealRate", () => {
it("returns null when pacing is off (undefined / false)", () => {
expect(resolveRevealRate(undefined)).toBeNull();
expect(resolveRevealRate(false)).toBeNull();
});

it("returns the defaults for `true`", () => {
expect(resolveRevealRate(true)).toEqual(DEFAULT_REVEAL_RATE);
});

it("fills unspecified knobs from the defaults", () => {
expect(resolveRevealRate({ targetMs: 1100 })).toEqual({
targetMs: 1100,
minMsPerChar: DEFAULT_REVEAL_RATE.minMsPerChar,
minTotalMs: DEFAULT_REVEAL_RATE.minTotalMs,
});
});

it("passes all knobs through when fully specified", () => {
const opts = { targetMs: 800, minMsPerChar: 2, minTotalMs: 200 };
expect(resolveRevealRate(opts)).toEqual(opts);
});
});

// ── revealDurationMs ─────────────────────────────────────────────────────────

describe("revealDurationMs", () => {
const opts = DEFAULT_REVEAL_RATE; // targetMs 1000, minMsPerChar 1.2, minTotalMs 400

it("is 0 when there is nothing left to reveal", () => {
expect(revealDurationMs(0, opts)).toBe(0);
expect(revealDurationMs(-5, opts)).toBe(0);
});

it("floors a tiny burst at minTotalMs (so it still animates)", () => {
// 10 chars * 1.2 = 12ms, below the 400ms floor
expect(revealDurationMs(10, opts)).toBe(opts.minTotalMs);
});

it("caps a large burst at targetMs (so it still finishes promptly)", () => {
// 5000 chars * 1.2 = 6000ms, above the 1000ms cap
expect(revealDurationMs(5000, opts)).toBe(opts.targetMs);
});

it("scales linearly with the burst size between the floor and the cap", () => {
// 500 chars * 1.2 = 600ms, inside [400, 1000]
expect(revealDurationMs(500, opts)).toBe(600);
});
});

// ── revealedCountAt ──────────────────────────────────────────────────────────

describe("revealedCountAt", () => {
it("reveals nothing new at elapsed 0", () => {
expect(revealedCountAt(20, 30, 0, 1000)).toBe(20);
});

it("reveals everything once the duration has elapsed", () => {
expect(revealedCountAt(20, 30, 1000, 1000)).toBe(50);
expect(revealedCountAt(20, 30, 5000, 1000)).toBe(50);
});

it("never exceeds startLen + remaining", () => {
expect(revealedCountAt(20, 30, 999999, 1000)).toBe(50);
});

it("returns startLen when there is nothing to reveal", () => {
expect(revealedCountAt(50, 0, 500, 1000)).toBe(50);
});

it("reveals everything immediately for a non-positive duration", () => {
expect(revealedCountAt(0, 40, 0, 0)).toBe(40);
});

it("clamps negative elapsed to the start", () => {
expect(revealedCountAt(10, 20, -100, 1000)).toBe(10);
});

it("is monotonic non-decreasing in elapsed time", () => {
let prev = -1;
for (let elapsed = 0; elapsed <= 1200; elapsed += 25) {
const count = revealedCountAt(0, 100, elapsed, 1000);
expect(count).toBeGreaterThanOrEqual(prev);
expect(count).toBeLessThanOrEqual(100);
prev = count;
}
});

it("carries a growing target forward without ever going backwards (the never-reset invariant)", () => {
// Simulate the hook's use: reveal to the end of one burst, then a second
// burst grows the target. The count continues from where it was, so the
// revealed length is monotonic across the whole sequence.
const first = revealedCountAt(0, 40, 1000, 1000); // burst 1: 0 → 40
expect(first).toBe(40);
// burst 2 appends 30 chars → new remaining is 30 from startLen 40
const secondStart = revealedCountAt(first, 30, 0, 600); // still 40 at elapsed 0
expect(secondStart).toBe(40);
const secondEnd = revealedCountAt(first, 30, 600, 600); // 40 → 70
expect(secondEnd).toBe(70);
expect(secondEnd).toBeGreaterThanOrEqual(first);
});
});
76 changes: 76 additions & 0 deletions packages/react-lang/src/hooks/useRevealedResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { type RevealRate, resolveRevealRate, revealDurationMs, revealedCountAt } from "../reveal";

function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}

/**
* Pace `response` into a growing prefix while streaming, so the parser assembles
* the UI over `revealRate.targetMs` instead of painting a bursty response at once.
* See reveal.ts for the why. Returns the response unchanged when pacing is off,
* `prefers-reduced-motion` is set, or the stream has settled.
*
* The revealed length is count-based and only ever advances toward the current
* response length (clamped down only if the response itself gets shorter, e.g. a
* new turn). It never resets to zero mid-stream, so a non-append-only DSL change
* (a new element growing the root's child-ref list) keeps typing forward instead
* of re-assembling from blank; the streaming parser reconciles the shift by
* statement id.
*/
export function useRevealedResponse(
response: string | null,
isStreaming: boolean,
revealRate: RevealRate | undefined,
): string | null {
const opts = useMemo(() => resolveRevealRate(revealRate), [revealRate]);
const enabled = useMemo(() => opts !== null && !prefersReducedMotion(), [opts]);

const full = response ?? "";
const [count, setCount] = useState(() => (enabled && isStreaming ? 0 : full.length));

const countRef = useRef(count);
countRef.current = count;
const rafRef = useRef<number | null>(null);

useEffect(() => {
if (!enabled || !isStreaming || opts === null) {
setCount(full.length);
return;
}
// Continue from the current count toward the current length — never reset.
const startLen = Math.min(countRef.current, full.length);
const remaining = full.length - startLen;
if (remaining <= 0) {
setCount(full.length);
return;
}
const durationMs = revealDurationMs(remaining, opts);
let startTs: number | null = null;
const step = (ts: number) => {
if (startTs === null) startTs = ts;
const next = revealedCountAt(startLen, remaining, ts - startTs, durationMs);
setCount(next);
if (next < startLen + remaining) {
rafRef.current = requestAnimationFrame(step);
} else {
rafRef.current = null;
}
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
// Re-run when the target grows (more streamed in) — the reveal continues
// from the current count, never restarting.
}, [enabled, isStreaming, full, opts]);

if (response === null) return null;
if (!enabled) return response;
return response.slice(0, Math.min(count, response.length));
}
2 changes: 2 additions & 0 deletions packages/react-lang/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type {
// openui-lang renderer
export { Renderer } from "./Renderer";
export type { RendererProps } from "./Renderer";
export { DEFAULT_REVEAL_RATE } from "./reveal";
export type { RevealRate, RevealRateOptions } from "./reveal";

// openui-lang action types
export { ACTION_STEPS, BuiltinActionType } from "@openuidev/lang-core";
Expand Down
Loading