import React, { type ComponentType, type ErrorInfo, type ReactNode, useCallback, useMemo, } from "react"; import type { UIElement, Spec, ActionBinding, Catalog, SchemaDefinition, LegacyCatalog, ComponentDefinition, } from "@json-render/core"; import { resolveElementProps, getByPath } from "@json-render/core"; import type { Components, Actions, ActionFn, SetState, StateModel, } from "./catalog-types"; import { useIsVisible, useVisibility } from "./contexts/visibility"; import { useActions } from "./contexts/actions"; import { useStateStore } from "./contexts/state"; import { StateProvider } from "./contexts/state"; import { VisibilityProvider } from "./contexts/visibility"; import { ActionProvider } from "./contexts/actions"; import { ValidationProvider } from "./contexts/validation"; import { ConfirmDialog } from "./contexts/actions"; import { standardComponents } from "./components/standard"; import { RepeatScopeProvider, useRepeatScope, rewriteRepeatTokens, } from "./contexts/repeat-scope"; /** * Props passed to component renderers */ export interface ComponentRenderProps

> { /** The element being rendered */ element: UIElement; /** Rendered children */ children?: ReactNode; /** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. */ emit?: (event: string) => void; /** Whether the parent is loading */ loading?: boolean; } /** * Component renderer type */ export type ComponentRenderer

> = ComponentType< ComponentRenderProps

>; /** * Registry of component renderers */ export type ComponentRegistry = Record>; /** * Props for the Renderer component */ export interface RendererProps { /** The UI spec to render */ spec: Spec | null; /** * Component registry. If omitted, only standard components are used. * When provided, custom components are merged with (and override) standard components. */ registry?: ComponentRegistry; /** Whether to include standard components (default: true) */ includeStandard?: boolean; /** Whether the spec is currently loading/streaming */ loading?: boolean; /** Fallback component for unknown types */ fallback?: ComponentRenderer; } // --------------------------------------------------------------------------- // ElementErrorBoundary – catches rendering errors in individual elements so // a single bad component never crashes the whole page. // --------------------------------------------------------------------------- interface ElementErrorBoundaryProps { elementType: string; children: ReactNode; } interface ElementErrorBoundaryState { hasError: boolean; } class ElementErrorBoundary extends React.Component< ElementErrorBoundaryProps, ElementErrorBoundaryState > { constructor(props: ElementErrorBoundaryProps) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(): ElementErrorBoundaryState { return { hasError: true }; } componentDidCatch(error: Error, info: ErrorInfo) { console.error( `[json-render] Rendering error in <${this.props.elementType}>:`, error, info.componentStack, ); } render() { if (this.state.hasError) { // Render nothing – the element silently disappears rather than // crashing the entire application. return null; } return this.props.children; } } /** * Element renderer component */ function ElementRenderer({ element, spec, registry, loading, fallback, }: { element: UIElement; spec: Spec; registry: ComponentRegistry; loading?: boolean; fallback?: ComponentRenderer; }) { const repeatScope = useRepeatScope(); const { ctx } = useVisibility(); const { execute } = useActions(); // ---- Rewrite $item / $index tokens when inside a Repeat ---- let effectiveElement = element; if (repeatScope) { const rewrittenProps = rewriteRepeatTokens( element.props, repeatScope.basePath, repeatScope.index, ); const rewrittenVisible = element.visible !== undefined ? rewriteRepeatTokens( element.visible, repeatScope.basePath, repeatScope.index, ) : element.visible; const rewrittenOn = element.on !== undefined ? rewriteRepeatTokens( element.on, repeatScope.basePath, repeatScope.index, ) : element.on; if ( rewrittenProps !== element.props || rewrittenVisible !== element.visible || rewrittenOn !== element.on ) { effectiveElement = { ...element, props: rewrittenProps as Record, visible: rewrittenVisible as UIElement["visible"], on: rewrittenOn as UIElement["on"], }; } } // Evaluate visibility (after token rewriting so paths are absolute) const isVisible = useIsVisible(effectiveElement.visible); // Create emit function that resolves events to action bindings. // Must be called before any early return to satisfy Rules of Hooks. const onBindings = effectiveElement.on; const emit = useCallback( (eventName: string) => { const binding = onBindings?.[eventName]; if (!binding) return; const bindings = Array.isArray(binding) ? binding : [binding]; for (const b of bindings) { execute(b); } }, [onBindings, execute], ); // Don't render if not visible if (!isVisible) { return null; } // Resolve dynamic prop expressions ($path, $cond/$then/$else) const resolvedProps = resolveElementProps( effectiveElement.props as Record, ctx, ); const resolvedElement = resolvedProps !== effectiveElement.props ? { ...effectiveElement, props: resolvedProps } : effectiveElement; // Get the component renderer const Component = registry[resolvedElement.type] ?? fallback; if (!Component) { console.warn(`No renderer for component type: ${resolvedElement.type}`); return null; } // ---- Render children (with repeat support) ---- const children = resolvedElement.repeat ? ( ) : ( resolvedElement.children?.map((childKey) => { const childElement = spec.elements[childKey]; if (!childElement) { if (!loading) { console.warn( `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`, ); } return null; } return ( ); }) ); return ( {children} ); } // --------------------------------------------------------------------------- // RepeatChildren -- renders child elements once per item in a state array. // Used when an element has a `repeat` field. // --------------------------------------------------------------------------- function RepeatChildren({ element, spec, registry, loading, fallback, }: { element: UIElement; spec: Spec; registry: ComponentRegistry; loading?: boolean; fallback?: ComponentRenderer; }) { const { state } = useStateStore(); const repeat = element.repeat!; const statePath = repeat.path; const items = (getByPath(state, statePath) as unknown[] | undefined) ?? []; return ( <> {items.map((item, index) => { // Use a stable key: prefer key field, fall back to index const key = repeat.key && typeof item === "object" && item !== null ? String((item as Record)[repeat.key] ?? index) : String(index); return ( {element.children?.map((childKey) => { const childElement = spec.elements[childKey]; if (!childElement) { if (!loading) { console.warn( `[json-render] Missing element "${childKey}" referenced as child of "${element.type}" (repeat). This element will not render.`, ); } return null; } return ( ); })} ); })} ); } /** * Main renderer component. * * By default, standard React Native components are included. * Custom components in `registry` override standard ones with the same name. * * @example * ```tsx * // Use standard components only * * * // Add/override components * * * // Disable standard components entirely * * ``` */ export function Renderer({ spec, registry: customRegistry, includeStandard = true, loading, fallback, }: RendererProps) { // Merge standard + custom components (custom overrides standard) const registry: ComponentRegistry = useMemo( () => ({ ...(includeStandard ? standardComponents : {}), ...customRegistry, }), [customRegistry, includeStandard], ); if (!spec || !spec.root) { return null; } const rootElement = spec.elements[spec.root]; if (!rootElement) { return null; } return ( ); } // Re-export standard components and action handlers export { standardComponents, createStandardActionHandlers, } from "./components/standard"; /** * Props for JSONUIProvider */ export interface JSONUIProviderProps { /** * Component registry. If omitted, only standard components are used. * Custom components are merged with (and override) standard components. */ registry?: ComponentRegistry; /** Initial state model */ initialState?: Record; /** Auth state */ authState?: { isSignedIn: boolean; user?: Record }; /** Action handlers */ actionHandlers?: Record< string, (params: Record) => Promise | unknown >; /** Navigation function */ navigate?: (path: string) => void; /** Custom validation functions */ validationFunctions?: Record< string, (value: unknown, args?: Record) => boolean >; /** Callback when state changes */ onStateChange?: (path: string, value: unknown) => void; children: ReactNode; } /** * Combined provider for all JSONUI contexts */ export function JSONUIProvider({ registry, initialState, authState, actionHandlers, navigate, validationFunctions, onStateChange, children, }: JSONUIProviderProps) { return ( {children} ); } /** * Renders the confirmation dialog when needed */ function ConfirmationDialogManager() { const { pendingConfirmation, confirm, cancel } = useActions(); if (!pendingConfirmation?.action.confirm) { return null; } return ( ); } /** * Legacy helper to create a renderer component from a catalog * @deprecated Use createRenderer with the new catalog API instead */ export function createRendererFromCatalog< C extends LegacyCatalog>, >( _catalog: C, registry: ComponentRegistry, ): ComponentType> { return function CatalogRenderer(props: Omit) { return ; }; } // ============================================================================ // defineRegistry // ============================================================================ /** * Result returned by defineRegistry */ export interface DefineRegistryResult { /** Component registry for `` */ registry: ComponentRegistry; /** * Create ActionProvider-compatible handlers. * Accepts getter functions so handlers always read the latest state/setState * (e.g. from React refs). */ handlers: ( getSetState: () => SetState | undefined, getState: () => StateModel, ) => Record) => Promise>; /** * Execute an action by name imperatively * (for use outside the React tree, e.g. initial state loading). */ executeAction: ( actionName: string, params: Record | undefined, setState: SetState, state?: StateModel, ) => Promise; } /** * Create a registry from a catalog with components and/or actions. * * @example * ```tsx * // Components only * const { registry } = defineRegistry(catalog, { * components: { * Card: ({ props, children }) => ( * {props.title}{children} * ), * }, * }); * * // Actions only * const { handlers, executeAction } = defineRegistry(catalog, { * actions: { * viewCustomers: async (params, setState) => { ... }, * }, * }); * * // Both * const { registry, handlers, executeAction } = defineRegistry(catalog, { * components: { ... }, * actions: { ... }, * }); * ``` */ export function defineRegistry( _catalog: C, options: { components?: Components; actions?: Actions; }, ): DefineRegistryResult { // Build component registry const registry: ComponentRegistry = {}; if (options.components) { for (const [name, componentFn] of Object.entries(options.components)) { registry[name] = ({ element, children, emit, loading, }: ComponentRenderProps) => { return (componentFn as DefineRegistryComponentFn)({ props: element.props, children, emit, loading, }); }; } } // Build action helpers const actionMap = options.actions ? (Object.entries(options.actions) as Array< [string, DefineRegistryActionFn] >) : []; const handlers = ( getSetState: () => SetState | undefined, getState: () => StateModel, ): Record) => Promise> => { const result: Record< string, (params: Record) => Promise > = {}; for (const [name, actionFn] of actionMap) { result[name] = async (params) => { const setState = getSetState(); const state = getState(); if (setState) { await actionFn(params, setState, state); } }; } return result; }; const executeAction = async ( actionName: string, params: Record | undefined, setState: SetState, state: StateModel = {}, ): Promise => { const entry = actionMap.find(([name]) => name === actionName); if (entry) { await entry[1](params, setState, state); } else { console.warn(`Unknown action: ${actionName}`); } }; return { registry, handlers, executeAction }; } /** @internal */ type DefineRegistryComponentFn = (ctx: { props: unknown; children?: React.ReactNode; emit?: (event: string) => void; loading?: boolean; }) => React.ReactNode; /** @internal */ type DefineRegistryActionFn = ( params: Record | undefined, setState: SetState, state: StateModel, ) => Promise; // ============================================================================ // NEW API // ============================================================================ /** * Props for renderers created with createRenderer */ export interface CreateRendererProps { /** The spec to render (AI-generated JSON) */ spec: Spec | null; /** State context for dynamic values */ state?: Record; /** Action handler */ onAction?: (actionName: string, params?: Record) => void; /** Callback when state changes (e.g., from form inputs) */ onStateChange?: (path: string, value: unknown) => void; /** Whether the spec is currently loading/streaming */ loading?: boolean; /** Auth state for visibility conditions */ authState?: { isSignedIn: boolean; user?: Record }; /** Fallback component for unknown types */ fallback?: ComponentRenderer; } /** * Component map type - maps component names to React Native components */ export type ComponentMap< TComponents extends Record, > = { [K in keyof TComponents]: ComponentType< ComponentRenderProps< TComponents[K]["props"] extends { _output: infer O } ? O : Record > >; }; /** * Create a renderer from a catalog * * @example * ```typescript * const DashboardRenderer = createRenderer(dashboardCatalog, { * Card: ({ element, children }) => {children}, * Metric: ({ element }) => {element.props.value}, * }); * * // Usage * * ``` */ export function createRenderer< TDef extends SchemaDefinition, TCatalog extends { components: Record }, >( catalog: Catalog, components: ComponentMap, ): ComponentType { // Convert component map to registry const registry: ComponentRegistry = components as unknown as ComponentRegistry; // Return the renderer component return function CatalogRenderer({ spec, state, onAction, onStateChange, loading, authState, fallback, }: CreateRendererProps) { // Wrap onAction to match internal API const actionHandlers = onAction ? { __default__: (params: Record) => { const actionName = params.__actionName__ as string; const actionParams = params.__actionParams__ as Record< string, unknown >; return onAction(actionName, actionParams); }, } : undefined; return ( ); }; }