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 (
);
};
}