import type { ComponentType, ErrorInfo, ReactNode } from "react"; import { Component, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { Alert, Box, Stack, Text } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; import { renderSafeJsx } from "../jsx/interpreter"; import { CustomJsxInputsProvider } from "../jsx/runtime-components"; import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components"; import { sameInputRecord, sameStringArray, sameTypeRecord } from "./input-state-utils"; export { CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./request-capabilities"; const EMPTY_RECORD: Record = {}; export interface CustomJsxRendererMessages { noTemplate: string; templateWarnings(count: number): string; bindingTypeConflict(name: string, firstType: WidgetInputType, secondType: WidgetInputType): string; } export interface CustomJsxRendererProps { template: string; data: unknown; status?: Record; options?: Record; components: Readonly>>; createBindings(data: unknown): Readonly>; messages: CustomJsxRendererMessages; } class RendererErrorBoundary extends Component< { children: ReactNode; resetKey: string; onError(error: Error): void }, { error: Error | null; resetKey: string } > { public state = { error: null, resetKey: "" } as { error: Error | null; resetKey: string }; public static getDerivedStateFromProps( props: Readonly<{ resetKey: string }>, state: Readonly<{ error: Error | null; resetKey: string }>, ) { return props.resetKey === state.resetKey ? null : { error: null, resetKey: props.resetKey }; } public static getDerivedStateFromError(error: Error) { return { error }; } public componentDidCatch(error: Error, _info: ErrorInfo) { this.props.onError(error); } public render() { return this.state.error ? : this.props.children; } } function ErrorAlert({ error }: { error: Error }) { return ( } p="xs"> RUNTIME_RENDER_ERROR {error.message} ); } export function CustomJsxRenderer(props: CustomJsxRendererProps) { if (!props.template.trim()) return ( {props.messages.noTemplate} ); return ; } interface InputRegistration { name: string; type: WidgetInputType; initialValue: WidgetInputValue; } interface InputState { values: Record; types: Record; } function CustomJsxRendererSession({ template, data, status = EMPTY_RECORD, options = EMPTY_RECORD, components, createBindings, messages, }: CustomJsxRendererProps) { const inputScopeId = useId(); const [parseErrors, setParseErrors] = useState([]); const [bindingErrors, setBindingErrors] = useState([]); const [inputState, setInputState] = useState({ values: {}, types: {} }); const { values: inputs, types: inputTypes } = inputState; const registrations = useRef(new Map()); const registrationVersion = useRef(0); const mounted = useRef(true); const bindingTypeConflict = messages.bindingTypeConflict; useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const reconcileInputs = useCallback(() => { if (!mounted.current) return; const active = new Map(); const conflicts: string[] = []; for (const registration of registrations.current.values()) { const existing = active.get(registration.name); if (!existing) { active.set(registration.name, registration); continue; } if (existing.type === registration.type) continue; const message = bindingTypeConflict(registration.name, existing.type, registration.type); if (!conflicts.includes(message) && conflicts.length < 5) conflicts.push(message); } setBindingErrors((current) => (sameStringArray(current, conflicts) ? current : conflicts)); setInputState((current) => { const nextTypes: Record = {}; for (const [name, registration] of active) nextTypes[name] = registration.type; const nextValues: Record = {}; for (const [name, registration] of active) { if (current.types[name] === registration.type && Object.hasOwn(current.values, name)) { const previousValue = current.values[name]; if (previousValue !== undefined) { nextValues[name] = previousValue; continue; } } nextValues[name] = registration.initialValue; } if (sameTypeRecord(current.types, nextTypes) && sameInputRecord(current.values, nextValues)) return current; return { values: nextValues, types: nextTypes }; }); }, [bindingTypeConflict]); const registerInput = useCallback( (name: string, type: WidgetInputType, initialValue: WidgetInputValue) => { const id = Symbol(name); registrations.current.set(id, { name, type, initialValue }); registrationVersion.current += 1; reconcileInputs(); return () => { registrations.current.delete(id); registrationVersion.current += 1; const version = registrationVersion.current; queueMicrotask(() => { if (registrationVersion.current === version) reconcileInputs(); }); }; }, [reconcileInputs], ); const setInputValue = useCallback((name: string, type: WidgetInputType, value: WidgetInputValue) => { setInputState((current) => { const existing = current.types[name]; if (existing && existing !== type) return current; if (existing === type && Object.is(current.values[name], value)) return current; return { values: { ...current.values, [name]: value }, types: existing === type ? current.types : { ...current.types, [name]: type }, }; }); }, []); const resetInput = useCallback((name: string, type: WidgetInputType) => { let activeRegistration: InputRegistration | undefined; for (const registration of registrations.current.values()) { if (registration.name !== name) continue; activeRegistration = registration; break; } if (!activeRegistration || activeRegistration.type !== type) return; setInputState((current) => { if (current.types[name] !== type || Object.is(current.values[name], activeRegistration.initialValue)) { return current; } return { ...current, values: { ...current.values, [name]: activeRegistration.initialValue } }; }); }, []); const rendered = useMemo(() => { try { const bindings = { ...createBindings(data), status, options, inputs }; return { ...renderSafeJsx({ template, components, bindings }), boundaryKey: createBoundaryKey(template, bindings), error: null, }; } catch (error) { return { node: null, warnings: [], boundaryKey: `${template.length}:error`, error: error instanceof Error ? error : new Error(String(error)), }; } }, [components, createBindings, data, inputs, options, status, template]); useEffect(() => setParseErrors([]), [rendered.boundaryKey, template]); const handleError = useCallback( (error: Error) => setParseErrors((current) => { if (current.length >= 5 || current.includes(error.message)) return current; return [...current, error.message]; }), [], ); useEffect(() => { rendered.warnings.forEach((warning) => handleError(new Error(warning))); if (rendered.error) handleError(rendered.error); }, [handleError, rendered]); return ( {rendered.error ? ( ) : ( {rendered.node} )} {[...parseErrors, ...bindingErrors].length > 0 && ( {messages.templateWarnings(parseErrors.length + bindingErrors.length)} {[...parseErrors, ...bindingErrors].map((message) => ( {message} ))} )} ); } function createBoundaryKey(template: string, bindings: Readonly>) { let hash = 0; const value = `${template}\0${JSON.stringify(bindings)}`; for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0; return `${template.length}:${hash}`; }