renderer.tsx 20 KB

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