renderer.tsx 26 KB

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