renderer.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  1. "use client";
  2. import React, {
  3. type ComponentType,
  4. type ErrorInfo,
  5. type ReactNode,
  6. useCallback,
  7. useEffect,
  8. useMemo,
  9. useRef,
  10. } from "react";
  11. import type {
  12. UIElement,
  13. Spec,
  14. ActionBinding,
  15. Catalog,
  16. SchemaDefinition,
  17. StateStore,
  18. ComputedFunction,
  19. } from "@json-render/core";
  20. import {
  21. resolveElementProps,
  22. resolveBindings,
  23. resolveActionParam,
  24. evaluateVisibility,
  25. getByPath,
  26. type PropResolutionContext,
  27. type VisibilityContext as CoreVisibilityContext,
  28. } from "@json-render/core";
  29. import type {
  30. Components,
  31. Actions,
  32. ActionFn,
  33. SetState,
  34. StateModel,
  35. CatalogHasActions,
  36. EventHandle,
  37. } from "./catalog-types";
  38. import { useIsVisible, useVisibility } from "./contexts/visibility";
  39. import { useActions } from "./contexts/actions";
  40. import { useStateStore } from "./contexts/state";
  41. import { StateProvider } from "./contexts/state";
  42. import { VisibilityProvider } from "./contexts/visibility";
  43. import { ActionProvider } from "./contexts/actions";
  44. import { ValidationProvider } from "./contexts/validation";
  45. import { ConfirmDialog } from "./contexts/actions";
  46. import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
  47. /**
  48. * Props passed to component renderers
  49. */
  50. export interface ComponentRenderProps<P = Record<string, unknown>> {
  51. /** The element being rendered */
  52. element: UIElement<string, P>;
  53. /** Rendered children */
  54. children?: ReactNode;
  55. /** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */
  56. emit: (event: string) => void;
  57. /** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */
  58. on: (event: string) => EventHandle;
  59. /**
  60. * Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
  61. * Maps prop name → absolute state path for write-back.
  62. * Only present when at least one prop uses `{ $bindState: "..." }` or `{ $bindItem: "..." }`.
  63. */
  64. bindings?: Record<string, string>;
  65. /** Whether the parent is loading */
  66. loading?: boolean;
  67. }
  68. /**
  69. * Component renderer type
  70. */
  71. export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
  72. ComponentRenderProps<P>
  73. >;
  74. /**
  75. * Registry of component renderers
  76. */
  77. export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
  78. /**
  79. * Props for the Renderer component
  80. */
  81. export interface RendererProps {
  82. /** The UI spec to render */
  83. spec: Spec | null;
  84. /** Component registry */
  85. registry: ComponentRegistry;
  86. /** Whether the spec is currently loading/streaming */
  87. loading?: boolean;
  88. /** Fallback component for unknown types */
  89. fallback?: ComponentRenderer;
  90. }
  91. // ---------------------------------------------------------------------------
  92. // ElementErrorBoundary – catches rendering errors in individual elements so
  93. // a single bad component never crashes the whole page.
  94. // ---------------------------------------------------------------------------
  95. interface ElementErrorBoundaryProps {
  96. elementType: string;
  97. children: ReactNode;
  98. }
  99. interface ElementErrorBoundaryState {
  100. hasError: boolean;
  101. }
  102. class ElementErrorBoundary extends React.Component<
  103. ElementErrorBoundaryProps,
  104. ElementErrorBoundaryState
  105. > {
  106. constructor(props: ElementErrorBoundaryProps) {
  107. super(props);
  108. this.state = { hasError: false };
  109. }
  110. static getDerivedStateFromError(): ElementErrorBoundaryState {
  111. return { hasError: true };
  112. }
  113. componentDidCatch(error: Error, info: ErrorInfo) {
  114. console.error(
  115. `[json-render] Rendering error in <${this.props.elementType}>:`,
  116. error,
  117. info.componentStack,
  118. );
  119. }
  120. render() {
  121. if (this.state.hasError) {
  122. // Render nothing – the element silently disappears rather than
  123. // crashing the entire application.
  124. return null;
  125. }
  126. return this.props.children;
  127. }
  128. }
  129. // ---------------------------------------------------------------------------
  130. // FunctionsContext – provides $computed functions to the element tree
  131. // ---------------------------------------------------------------------------
  132. const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
  133. const FunctionsContext =
  134. React.createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
  135. function useFunctions(): Record<string, ComputedFunction> {
  136. return React.useContext(FunctionsContext);
  137. }
  138. interface ElementRendererProps {
  139. element: UIElement;
  140. spec: Spec;
  141. registry: ComponentRegistry;
  142. loading?: boolean;
  143. fallback?: ComponentRenderer;
  144. }
  145. /**
  146. * Element renderer component.
  147. * Memoized to prevent re-rendering all repeat children when state changes.
  148. */
  149. const ElementRenderer = React.memo(function ElementRenderer({
  150. element,
  151. spec,
  152. registry,
  153. loading,
  154. fallback,
  155. }: ElementRendererProps) {
  156. const repeatScope = useRepeatScope();
  157. const { ctx } = useVisibility();
  158. const { execute } = useActions();
  159. const { getSnapshot, state: watchState } = useStateStore();
  160. const functions = useFunctions();
  161. // Build context with repeat scope and $computed functions
  162. const fullCtx: PropResolutionContext = useMemo(() => {
  163. const base: PropResolutionContext = repeatScope
  164. ? {
  165. ...ctx,
  166. repeatItem: repeatScope.item,
  167. repeatIndex: repeatScope.index,
  168. repeatBasePath: repeatScope.basePath,
  169. }
  170. : { ...ctx };
  171. base.functions = functions;
  172. return base;
  173. }, [ctx, repeatScope, functions]);
  174. // Evaluate visibility (now supports $item/$index inside repeat scopes)
  175. const isVisible =
  176. element.visible === undefined
  177. ? true
  178. : evaluateVisibility(element.visible, fullCtx);
  179. // Create emit function that resolves events to action bindings.
  180. // Must be called before any early return to satisfy Rules of Hooks.
  181. const onBindings = element.on;
  182. const emit = useCallback(
  183. async (eventName: string) => {
  184. const binding = onBindings?.[eventName];
  185. if (!binding) return;
  186. const actionBindings = Array.isArray(binding) ? binding : [binding];
  187. for (const b of actionBindings) {
  188. if (!b.params) {
  189. await execute(b);
  190. continue;
  191. }
  192. // Build a fresh context with live store state so that $state
  193. // references in later actions see mutations from earlier ones.
  194. const liveCtx: PropResolutionContext = {
  195. ...fullCtx,
  196. stateModel: getSnapshot(),
  197. };
  198. const resolved: Record<string, unknown> = {};
  199. for (const [key, val] of Object.entries(b.params)) {
  200. resolved[key] = resolveActionParam(val, liveCtx);
  201. }
  202. await execute({ ...b, params: resolved });
  203. }
  204. },
  205. [onBindings, execute, fullCtx, getSnapshot],
  206. );
  207. // Create on() function that returns an EventHandle with metadata for a specific event.
  208. const on = useCallback(
  209. (eventName: string): EventHandle => {
  210. const binding = onBindings?.[eventName];
  211. if (!binding) {
  212. return { emit: () => {}, shouldPreventDefault: false, bound: false };
  213. }
  214. const actionBindings = Array.isArray(binding) ? binding : [binding];
  215. const shouldPreventDefault = actionBindings.some((b) => b.preventDefault);
  216. return {
  217. emit: () => emit(eventName),
  218. shouldPreventDefault,
  219. bound: true,
  220. };
  221. },
  222. [onBindings, emit],
  223. );
  224. // Watch effect: fire actions when watched state paths change.
  225. // Must be called before any early return to satisfy Rules of Hooks.
  226. //
  227. // Two refs serve distinct roles:
  228. // - `stableWatchRef` (useMemo): holds the last emitted values object so we
  229. // can return the same reference when watched values haven't changed,
  230. // preventing the downstream useEffect from firing on unrelated state updates.
  231. // - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot
  232. // for change detection. Starts as `null` to skip the initial mount.
  233. const watchConfig = element.watch;
  234. const prevWatchValues = useRef<Record<string, unknown> | null>(null);
  235. const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
  236. const watchedValues = useMemo(() => {
  237. if (!watchConfig) return undefined;
  238. const values: Record<string, unknown> = {};
  239. for (const path of Object.keys(watchConfig)) {
  240. values[path] = getByPath(watchState, path);
  241. }
  242. const prev = stableWatchRef.current;
  243. if (prev) {
  244. const keys = Object.keys(values);
  245. if (
  246. keys.length === Object.keys(prev).length &&
  247. keys.every((k) => values[k] === prev[k])
  248. ) {
  249. return prev;
  250. }
  251. }
  252. stableWatchRef.current = values;
  253. return values;
  254. }, [watchConfig, watchState]);
  255. useEffect(() => {
  256. if (!watchConfig || !watchedValues) return;
  257. const paths = Object.keys(watchConfig);
  258. if (paths.length === 0) return;
  259. const prev = prevWatchValues.current;
  260. prevWatchValues.current = watchedValues;
  261. // Skip the initial mount — only fire on changes
  262. if (prev === null) return;
  263. let cancelled = false;
  264. void (async () => {
  265. for (const path of paths) {
  266. if (cancelled) break;
  267. if (watchedValues[path] !== prev[path]) {
  268. const binding = watchConfig[path];
  269. if (!binding) continue;
  270. const bindings = Array.isArray(binding) ? binding : [binding];
  271. for (const b of bindings) {
  272. if (cancelled) break;
  273. if (!b.params) {
  274. await execute(b);
  275. if (cancelled) break;
  276. continue;
  277. }
  278. const liveCtx: PropResolutionContext = {
  279. ...fullCtx,
  280. stateModel: getSnapshot(),
  281. };
  282. const resolved: Record<string, unknown> = {};
  283. for (const [key, val] of Object.entries(b.params)) {
  284. resolved[key] = resolveActionParam(val, liveCtx);
  285. }
  286. await execute({ ...b, params: resolved });
  287. if (cancelled) break;
  288. }
  289. }
  290. }
  291. })().catch(console.error);
  292. return () => {
  293. cancelled = true;
  294. };
  295. }, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]);
  296. // Don't render if not visible
  297. if (!isVisible) {
  298. return null;
  299. }
  300. // Resolve $bindState/$bindItem expressions → bindings map (prop name → state path)
  301. const rawProps = element.props as Record<string, unknown>;
  302. const elementBindings = resolveBindings(rawProps, fullCtx);
  303. // Resolve dynamic prop expressions ($state, $item, $index, $bindState, $bindItem, $cond/$then/$else)
  304. const resolvedProps = resolveElementProps(rawProps, fullCtx);
  305. const resolvedElement =
  306. resolvedProps !== element.props
  307. ? { ...element, props: resolvedProps }
  308. : element;
  309. // Get the component renderer
  310. const Component = registry[resolvedElement.type] ?? fallback;
  311. if (!Component) {
  312. console.warn(`No renderer for component type: ${resolvedElement.type}`);
  313. return null;
  314. }
  315. // ---- Render children (with repeat support) ----
  316. const children = resolvedElement.repeat ? (
  317. <RepeatChildren
  318. element={resolvedElement}
  319. spec={spec}
  320. registry={registry}
  321. loading={loading}
  322. fallback={fallback}
  323. />
  324. ) : (
  325. resolvedElement.children?.map((childKey) => {
  326. const childElement = spec.elements[childKey];
  327. if (!childElement) {
  328. if (!loading) {
  329. console.warn(
  330. `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
  331. );
  332. }
  333. return null;
  334. }
  335. return (
  336. <ElementRenderer
  337. key={childKey}
  338. element={childElement}
  339. spec={spec}
  340. registry={registry}
  341. loading={loading}
  342. fallback={fallback}
  343. />
  344. );
  345. })
  346. );
  347. return (
  348. <ElementErrorBoundary elementType={resolvedElement.type}>
  349. <Component
  350. element={resolvedElement}
  351. emit={emit}
  352. on={on}
  353. bindings={elementBindings}
  354. loading={loading}
  355. >
  356. {children}
  357. </Component>
  358. </ElementErrorBoundary>
  359. );
  360. });
  361. // ---------------------------------------------------------------------------
  362. // RepeatChildren -- renders child elements once per item in a state array.
  363. // Used when an element has a `repeat` field.
  364. // ---------------------------------------------------------------------------
  365. function RepeatChildren({
  366. element,
  367. spec,
  368. registry,
  369. loading,
  370. fallback,
  371. }: {
  372. element: UIElement;
  373. spec: Spec;
  374. registry: ComponentRegistry;
  375. loading?: boolean;
  376. fallback?: ComponentRenderer;
  377. }) {
  378. const { state } = useStateStore();
  379. const repeat = element.repeat!;
  380. const statePath = repeat.statePath;
  381. const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
  382. return (
  383. <>
  384. {items.map((itemValue, index) => {
  385. // Use a stable key: prefer key field, fall back to index
  386. const key =
  387. repeat.key && typeof itemValue === "object" && itemValue !== null
  388. ? String(
  389. (itemValue as Record<string, unknown>)[repeat.key] ?? index,
  390. )
  391. : String(index);
  392. return (
  393. <RepeatScopeProvider
  394. key={key}
  395. item={itemValue}
  396. index={index}
  397. basePath={`${statePath}/${index}`}
  398. >
  399. {element.children?.map((childKey) => {
  400. const childElement = spec.elements[childKey];
  401. if (!childElement) {
  402. if (!loading) {
  403. console.warn(
  404. `[json-render] Missing element "${childKey}" referenced as child of "${element.type}" (repeat). This element will not render.`,
  405. );
  406. }
  407. return null;
  408. }
  409. return (
  410. <ElementRenderer
  411. key={childKey}
  412. element={childElement}
  413. spec={spec}
  414. registry={registry}
  415. loading={loading}
  416. fallback={fallback}
  417. />
  418. );
  419. })}
  420. </RepeatScopeProvider>
  421. );
  422. })}
  423. </>
  424. );
  425. }
  426. /**
  427. * Main renderer component
  428. */
  429. export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
  430. if (!spec || !spec.root) {
  431. return null;
  432. }
  433. const rootElement = spec.elements[spec.root];
  434. if (!rootElement) {
  435. return null;
  436. }
  437. return (
  438. <ElementRenderer
  439. element={rootElement}
  440. spec={spec}
  441. registry={registry}
  442. loading={loading}
  443. fallback={fallback}
  444. />
  445. );
  446. }
  447. /**
  448. * Props for JSONUIProvider
  449. */
  450. export interface JSONUIProviderProps {
  451. /** Component registry */
  452. registry: ComponentRegistry;
  453. /**
  454. * External store (controlled mode). When provided, `initialState` and
  455. * `onStateChange` are ignored.
  456. */
  457. store?: StateStore;
  458. /** Initial state model (uncontrolled mode) */
  459. initialState?: Record<string, unknown>;
  460. /** Action handlers */
  461. handlers?: Record<
  462. string,
  463. (params: Record<string, unknown>) => Promise<unknown> | unknown
  464. >;
  465. /** Navigation function */
  466. navigate?: (path: string) => void;
  467. /** Custom validation functions */
  468. validationFunctions?: Record<
  469. string,
  470. (value: unknown, args?: Record<string, unknown>) => boolean
  471. >;
  472. /** Named functions for `$computed` expressions in props */
  473. functions?: Record<string, ComputedFunction>;
  474. /** Callback when state changes (uncontrolled mode) */
  475. onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
  476. children: ReactNode;
  477. }
  478. /**
  479. * Combined provider for all JSONUI contexts
  480. */
  481. export function JSONUIProvider({
  482. registry,
  483. store,
  484. initialState,
  485. handlers,
  486. navigate,
  487. validationFunctions,
  488. functions,
  489. onStateChange,
  490. children,
  491. }: JSONUIProviderProps) {
  492. return (
  493. <StateProvider
  494. store={store}
  495. initialState={initialState}
  496. onStateChange={onStateChange}
  497. >
  498. <VisibilityProvider>
  499. <ValidationProvider customFunctions={validationFunctions}>
  500. <ActionProvider handlers={handlers} navigate={navigate}>
  501. <FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
  502. {children}
  503. <ConfirmationDialogManager />
  504. </FunctionsContext.Provider>
  505. </ActionProvider>
  506. </ValidationProvider>
  507. </VisibilityProvider>
  508. </StateProvider>
  509. );
  510. }
  511. /**
  512. * Renders the confirmation dialog when needed
  513. */
  514. function ConfirmationDialogManager() {
  515. const { pendingConfirmation, confirm, cancel } = useActions();
  516. if (!pendingConfirmation?.action.confirm) {
  517. return null;
  518. }
  519. return (
  520. <ConfirmDialog
  521. confirm={pendingConfirmation.action.confirm}
  522. onConfirm={confirm}
  523. onCancel={cancel}
  524. />
  525. );
  526. }
  527. // ============================================================================
  528. // defineRegistry
  529. // ============================================================================
  530. /**
  531. * Result returned by defineRegistry
  532. */
  533. export interface DefineRegistryResult {
  534. /** Component registry for `<Renderer registry={...} />` */
  535. registry: ComponentRegistry;
  536. /**
  537. * Create ActionProvider-compatible handlers.
  538. * Accepts getter functions so handlers always read the latest state/setState
  539. * (e.g. from React refs).
  540. */
  541. handlers: (
  542. getSetState: () => SetState | undefined,
  543. getState: () => StateModel,
  544. ) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
  545. /**
  546. * Execute an action by name imperatively
  547. * (for use outside the React tree, e.g. initial state loading).
  548. */
  549. executeAction: (
  550. actionName: string,
  551. params: Record<string, unknown> | undefined,
  552. setState: SetState,
  553. state?: StateModel,
  554. ) => Promise<void>;
  555. }
  556. /**
  557. * Options for defineRegistry.
  558. *
  559. * When the catalog declares actions, the `actions` field is required.
  560. * When the catalog has no actions (or `actions: {}`), the field is optional.
  561. */
  562. type DefineRegistryOptions<C extends Catalog> = {
  563. components?: Components<C>;
  564. } & (CatalogHasActions<C> extends true
  565. ? { actions: Actions<C> }
  566. : { actions?: Actions<C> });
  567. /**
  568. * Create a registry from a catalog with components and/or actions.
  569. *
  570. * When the catalog declares actions, the `actions` field is required.
  571. *
  572. * @example
  573. * ```tsx
  574. * // Components only (catalog has no actions)
  575. * const { registry } = defineRegistry(catalog, {
  576. * components: {
  577. * Card: ({ props, children }) => (
  578. * <div className="card">{props.title}{children}</div>
  579. * ),
  580. * },
  581. * });
  582. *
  583. * // Both (catalog declares actions)
  584. * const { registry, handlers, executeAction } = defineRegistry(catalog, {
  585. * components: { ... },
  586. * actions: { ... },
  587. * });
  588. * ```
  589. */
  590. export function defineRegistry<C extends Catalog>(
  591. _catalog: C,
  592. options: DefineRegistryOptions<C>,
  593. ): DefineRegistryResult {
  594. // Build component registry
  595. const registry: ComponentRegistry = {};
  596. if (options.components) {
  597. for (const [name, componentFn] of Object.entries(options.components)) {
  598. registry[name] = ({
  599. element,
  600. children,
  601. emit,
  602. on,
  603. bindings,
  604. loading,
  605. }: ComponentRenderProps) => {
  606. return (componentFn as DefineRegistryComponentFn)({
  607. props: element.props,
  608. children,
  609. emit,
  610. on,
  611. bindings,
  612. loading,
  613. });
  614. };
  615. }
  616. }
  617. // Build action helpers
  618. const actionMap = options.actions
  619. ? (Object.entries(options.actions) as Array<
  620. [string, DefineRegistryActionFn]
  621. >)
  622. : [];
  623. const handlers = (
  624. getSetState: () => SetState | undefined,
  625. getState: () => StateModel,
  626. ): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
  627. const result: Record<
  628. string,
  629. (params: Record<string, unknown>) => Promise<void>
  630. > = {};
  631. for (const [name, actionFn] of actionMap) {
  632. result[name] = async (params) => {
  633. const setState = getSetState();
  634. const state = getState();
  635. if (setState) {
  636. await actionFn(params, setState, state);
  637. }
  638. };
  639. }
  640. return result;
  641. };
  642. const executeAction = async (
  643. actionName: string,
  644. params: Record<string, unknown> | undefined,
  645. setState: SetState,
  646. state: StateModel = {},
  647. ): Promise<void> => {
  648. const entry = actionMap.find(([name]) => name === actionName);
  649. if (entry) {
  650. await entry[1](params, setState, state);
  651. } else {
  652. console.warn(`Unknown action: ${actionName}`);
  653. }
  654. };
  655. return { registry, handlers, executeAction };
  656. }
  657. /** @internal */
  658. type DefineRegistryComponentFn = (ctx: {
  659. props: unknown;
  660. children?: React.ReactNode;
  661. emit: (event: string) => void;
  662. on: (event: string) => EventHandle;
  663. bindings?: Record<string, string>;
  664. loading?: boolean;
  665. }) => React.ReactNode;
  666. /** @internal */
  667. type DefineRegistryActionFn = (
  668. params: Record<string, unknown> | undefined,
  669. setState: SetState,
  670. state: StateModel,
  671. ) => Promise<void>;
  672. // ============================================================================
  673. // NEW API
  674. // ============================================================================
  675. /**
  676. * Props for renderers created with createRenderer
  677. */
  678. export interface CreateRendererProps {
  679. /** The spec to render (AI-generated JSON) */
  680. spec: Spec | null;
  681. /**
  682. * External store (controlled mode). When provided, `state` and
  683. * `onStateChange` are ignored.
  684. */
  685. store?: StateStore;
  686. /** State context for dynamic values (uncontrolled mode) */
  687. state?: Record<string, unknown>;
  688. /** Action handler */
  689. onAction?: (actionName: string, params?: Record<string, unknown>) => void;
  690. /** Callback when state changes (uncontrolled mode) */
  691. onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
  692. /** Named functions for `$computed` expressions in props */
  693. functions?: Record<string, ComputedFunction>;
  694. /** Whether the spec is currently loading/streaming */
  695. loading?: boolean;
  696. /** Fallback component for unknown types */
  697. fallback?: ComponentRenderer;
  698. }
  699. /**
  700. * Component map type - maps component names to React components
  701. */
  702. export type ComponentMap<
  703. TComponents extends Record<string, { props: unknown }>,
  704. > = {
  705. [K in keyof TComponents]: ComponentType<
  706. ComponentRenderProps<
  707. TComponents[K]["props"] extends { _output: infer O }
  708. ? O
  709. : Record<string, unknown>
  710. >
  711. >;
  712. };
  713. /**
  714. * Create a renderer from a catalog
  715. *
  716. * @example
  717. * ```typescript
  718. * const DashboardRenderer = createRenderer(dashboardCatalog, {
  719. * Card: ({ element, children }) => <div className="card">{children}</div>,
  720. * Metric: ({ element }) => <span>{element.props.value}</span>,
  721. * });
  722. *
  723. * // Usage
  724. * <DashboardRenderer spec={aiGeneratedSpec} state={state} />
  725. * ```
  726. */
  727. export function createRenderer<
  728. TDef extends SchemaDefinition,
  729. TCatalog extends { components: Record<string, { props: unknown }> },
  730. >(
  731. catalog: Catalog<TDef, TCatalog>,
  732. components: ComponentMap<TCatalog["components"]>,
  733. ): ComponentType<CreateRendererProps> {
  734. // Convert component map to registry
  735. const registry: ComponentRegistry =
  736. components as unknown as ComponentRegistry;
  737. // Return the renderer component
  738. return function CatalogRenderer({
  739. spec,
  740. store,
  741. state,
  742. onAction,
  743. onStateChange,
  744. functions,
  745. loading,
  746. fallback,
  747. }: CreateRendererProps) {
  748. // Wrap onAction with a Proxy so any action name routes to the callback
  749. const actionHandlers = onAction
  750. ? new Proxy(
  751. {} as Record<
  752. string,
  753. (params: Record<string, unknown>) => void | Promise<void>
  754. >,
  755. {
  756. get: (_target, prop: string) => {
  757. return (params: Record<string, unknown>) =>
  758. onAction(prop, params);
  759. },
  760. has: () => true,
  761. },
  762. )
  763. : undefined;
  764. return (
  765. <StateProvider
  766. store={store}
  767. initialState={state}
  768. onStateChange={onStateChange}
  769. >
  770. <VisibilityProvider>
  771. <ValidationProvider>
  772. <ActionProvider handlers={actionHandlers}>
  773. <FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
  774. <Renderer
  775. spec={spec}
  776. registry={registry}
  777. loading={loading}
  778. fallback={fallback}
  779. />
  780. <ConfirmationDialogManager />
  781. </FunctionsContext.Provider>
  782. </ActionProvider>
  783. </ValidationProvider>
  784. </VisibilityProvider>
  785. </StateProvider>
  786. );
  787. };
  788. }