import React, {
type ComponentType,
type ErrorInfo,
type ReactNode,
useCallback,
useMemo,
} from "react";
import type {
UIElement,
Spec,
ActionBinding,
Catalog,
SchemaDefinition,
StateStore,
} from "@json-render/core";
import {
resolveElementProps,
resolveBindings,
resolveActionParam,
evaluateVisibility,
getByPath,
type PropResolutionContext,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import type { Components, 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 { standardComponents } from "./components/standard";
import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
// =============================================================================
// Types
// =============================================================================
export interface ComponentRenderProps
> {
element: UIElement;
children?: ReactNode;
emit: (event: string) => void;
bindings?: Record;
loading?: boolean;
}
export type ComponentRenderer> = ComponentType<
ComponentRenderProps
>;
export type ComponentRegistry = Record>;
export interface RendererProps {
spec: Spec | null;
registry?: ComponentRegistry;
includeStandard?: boolean;
loading?: boolean;
fallback?: ComponentRenderer;
}
// =============================================================================
// ElementErrorBoundary
// =============================================================================
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/react-pdf] Rendering error in <${this.props.elementType}>:`,
error,
info.componentStack,
);
}
render() {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
// =============================================================================
// ElementRenderer
// =============================================================================
interface ElementRendererProps {
element: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
}
const ElementRenderer = React.memo(function ElementRenderer({
element,
spec,
registry,
loading,
fallback,
}: ElementRendererProps) {
const repeatScope = useRepeatScope();
const { ctx } = useVisibility();
const { execute } = useActions();
const { getSnapshot } = useStateStore();
const fullCtx: PropResolutionContext = useMemo(
() =>
repeatScope
? {
...ctx,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
}
: ctx,
[ctx, repeatScope],
);
const isVisible =
element.visible === undefined
? true
: evaluateVisibility(element.visible, fullCtx);
const onBindings = element.on;
const emit = useCallback(
async (eventName: string) => {
const binding = onBindings?.[eventName];
if (!binding) return;
const actionBindings = Array.isArray(binding) ? binding : [binding];
for (const b of actionBindings) {
if (!b.params) {
await execute(b);
continue;
}
// Build a fresh context with live store state so that $state
// references in later actions see mutations from earlier ones.
const liveCtx: PropResolutionContext = {
...fullCtx,
stateModel: getSnapshot(),
};
const resolved: Record = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
}
},
[onBindings, execute, fullCtx, getSnapshot],
);
if (!isVisible) {
return null;
}
const rawProps = element.props as Record;
const elementBindings = resolveBindings(rawProps, fullCtx);
const resolvedProps = resolveElementProps(rawProps, fullCtx);
const resolvedElement =
resolvedProps !== element.props
? { ...element, props: resolvedProps }
: element;
const Component = registry[resolvedElement.type] ?? fallback;
if (!Component) {
console.warn(
`[json-render/react-pdf] No renderer for component type: ${resolvedElement.type}`,
);
return null;
}
const children = resolvedElement.repeat ? (
) : (
resolvedElement.children?.map((childKey) => {
const childElement = spec.elements[childKey];
if (!childElement) {
if (!loading) {
console.warn(
`[json-render/react-pdf] Missing element "${childKey}" referenced as child of "${resolvedElement.type}".`,
);
}
return null;
}
return (
);
})
);
return (
{children}
);
});
// =============================================================================
// RepeatChildren
// =============================================================================
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.statePath;
const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
return (
<>
{items.map((itemValue, index) => {
const key =
repeat.key && typeof itemValue === "object" && itemValue !== null
? String(
(itemValue 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/react-pdf] Missing element "${childKey}" referenced as child of "${element.type}" (repeat).`,
);
}
return null;
}
return (
);
})}
);
})}
>
);
}
// =============================================================================
// Renderer
// =============================================================================
export function Renderer({
spec,
registry: customRegistry,
includeStandard = true,
loading,
fallback,
}: RendererProps) {
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 (
);
}
// =============================================================================
// JSONUIProvider
// =============================================================================
export interface JSONUIProviderProps {
registry?: ComponentRegistry;
store?: StateStore;
initialState?: Record;
handlers?: Record<
string,
(params: Record) => Promise | unknown
>;
navigate?: (path: string) => void;
validationFunctions?: Record<
string,
(value: unknown, args?: Record) => boolean
>;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
children: ReactNode;
}
export function JSONUIProvider({
store,
initialState,
handlers,
navigate,
validationFunctions,
onStateChange,
children,
}: JSONUIProviderProps) {
return (
{children}
);
}
// =============================================================================
// defineRegistry
// =============================================================================
export interface DefineRegistryResult {
registry: ComponentRegistry;
}
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: React.ReactNode;
emit: (event: string) => void;
bindings?: Record;
loading?: boolean;
}) => React.ReactNode;
export function defineRegistry(
_catalog: C,
options: {
components?: Components;
},
): DefineRegistryResult {
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = ({
element,
children,
emit,
bindings,
loading,
}: ComponentRenderProps) => {
return (componentFn as DefineRegistryComponentFn)({
props: element.props,
children,
emit,
bindings,
loading,
});
};
}
}
return { registry };
}
// =============================================================================
// createRenderer
// =============================================================================
export interface CreateRendererProps {
spec: Spec | null;
store?: StateStore;
state?: Record;
onAction?: (actionName: string, params?: Record) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
loading?: boolean;
fallback?: ComponentRenderer;
}
export type ComponentMap<
TComponents extends Record,
> = {
[K in keyof TComponents]: ComponentType<
ComponentRenderProps<
TComponents[K]["props"] extends { _output: infer O }
? O
: Record
>
>;
};
export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record },
>(
catalog: Catalog,
components: ComponentMap,
): ComponentType {
const registry: ComponentRegistry =
components as unknown as ComponentRegistry;
return function CatalogRenderer({
spec,
store,
state,
onAction,
onStateChange,
loading,
fallback,
}: CreateRendererProps) {
const actionHandlers = onAction
? new Proxy(
{} as Record<
string,
(params: Record) => void | Promise
>,
{
get: (_target, prop: string) => {
return (params: Record) =>
onAction(prop, params);
},
has: () => true,
},
)
: undefined;
return (
);
};
}