renderer.tsx 18 KB

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