import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { EventHandler, PagesData, ComponentNode } from '../../common/types/component.types'; import type { ComponentEventType } from '../../common/registry/types'; import { interpolateText } from '../../common/utils/stateBinding'; import { updateStateAtPath } from '../../common/state/statePathUtils'; import { parseScopedPath } from '../../common/state/scopedPath'; import { EventExecutor } from '../../common/events'; import { RuntimeContext } from '../../common/runtime'; import type { RuntimeContextValue } from '../../common/runtime'; const isPathValidForNested = (path: string): boolean => { if (!path || typeof path !== 'string') return false; const trimmed = path.trim(); if (!trimmed || trimmed.startsWith('.') || trimmed.endsWith('.') || trimmed.includes('..')) return false; const pathPattern = /^[a-zA-Z0-9_. -]+$/; return pathPattern.test(trimmed); }; const safeObject = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; const deserializePageStateSnapshot = (pagesData: PagesData): Record> => { const raw = pagesData.pageStateData; if (!raw || typeof raw !== 'object') return {}; const byPageId: Record> = {}; for (const [pageId, stateData] of Object.entries(raw)) { const state = (stateData as any)?.state; byPageId[pageId] = safeObject(state); } return byPageId; }; export interface RendererRuntimeProviderProps { children: React.ReactNode; pagesData: PagesData; workflow?: { executeUrl: string }; currentRoute: string; onNavigate: (route: string) => void; } export const RendererRuntimeProvider: React.FC = ({ children, pagesData, workflow, currentRoute, onNavigate, }) => { const pages = pagesData.pages ?? []; const [globalState, setGlobalState] = useState>(() => safeObject(pagesData.stateData?.state)); const [pageStateById, setPageStateById] = useState>>(() => deserializePageStateSnapshot(pagesData) ); const currentPageId = useMemo(() => { const matched = pages.find((p) => p.route === currentRoute); if (matched) return matched.id; const fallback = pages.find((p) => p.isDefault) || pages[0]; return fallback?.id ?? null; }, [currentRoute, pages]); const currentPageState = useMemo(() => { if (!currentPageId) return {}; return pageStateById[currentPageId] ?? {}; }, [currentPageId, pageStateById]); const scopedState = useMemo(() => { return { page: currentPageState, global: globalState, }; }, [globalState, currentPageState]); const setScopedStateValueDirect = useCallback( (path: string, value: any): boolean => { let parsedPath; try { parsedPath = parseScopedPath(path); } catch (error) { console.error(error); return false; } if (!isPathValidForNested(parsedPath.relativePath)) return false; const parts = parsedPath.relativePath.split('.'); if (parsedPath.scope === 'page') { if (!currentPageId) return false; setPageStateById((prev) => { const current = prev[currentPageId] ?? {}; const next = updateStateAtPath(current, parts, value); return { ...prev, [currentPageId]: next }; }); return true; } setGlobalState((prev) => updateStateAtPath(prev, parts, value)); return true; }, [currentPageId] ); const setScopedStateValue = useCallback( (path: string, value: any): boolean => { let parsedPath; try { parsedPath = parseScopedPath(path); } catch (error) { console.error(error); return false; } if (!isPathValidForNested(parsedPath.relativePath)) return false; let processedValue: any = value; if (typeof value === 'string') { const trimmedValue = value.trim(); if (trimmedValue.toLowerCase() === 'true') { processedValue = true; } else if (trimmedValue.toLowerCase() === 'false') { processedValue = false; } else if (trimmedValue === 'null') { processedValue = null; } else { const numberMatch = trimmedValue.match(/^-?\d+\.?\d*$/); if (numberMatch) { processedValue = Number(trimmedValue); if (isNaN(processedValue)) { processedValue = value; } } else { processedValue = value; } } } return setScopedStateValueDirect(parsedPath.fullPath, processedValue); }, [setScopedStateValueDirect] ); const setScopedStateValueRaw = useCallback( (key: string, value: any): boolean => { let parsedPath; try { parsedPath = parseScopedPath(key); } catch (error) { console.error(error); return false; } if (!parsedPath.relativePath) return false; if (parsedPath.scope === 'page') { if (!currentPageId) return false; setPageStateById((prev) => { const current = prev[currentPageId] ?? {}; return { ...prev, [currentPageId]: { ...current, [parsedPath.relativePath]: value } }; }); return true; } setGlobalState((prev) => ({ ...prev, [parsedPath.relativePath]: value })); return true; }, [currentPageId] ); const navigateToPage = useCallback( (pageId: string) => { const page = pages.find((p) => p.id === pageId); if (page) { onNavigate(page.route); } }, [onNavigate, pages] ); const eventExecutor = useMemo(() => new EventExecutor({ workflow }), [workflow?.executeUrl]); useEffect(() => { eventExecutor.setContext({ setStateValue: setScopedStateValue, setStateValueRaw: setScopedStateValueRaw, setStateValueDirect: setScopedStateValueDirect, navigateToPage, pages, state: scopedState, }); }, [ eventExecutor, navigateToPage, pages, scopedState, setScopedStateValue, setScopedStateValueDirect, setScopedStateValueRaw, ]); const resolveText = useCallback( (text: string): string => { return interpolateText(text, scopedState); }, [scopedState] ); const executeHandlers = useCallback( async (handlers: EventHandler[]) => { if (handlers.length === 0) return; await eventExecutor.executeWithErrorHandling(handlers); }, [eventExecutor] ); const dispatchEvent = useCallback( async (_eventType: ComponentEventType, component: ComponentNode, handlersKey = 'eventHandlers') => { const handlers = (component.props?.[handlersKey] as EventHandler[]) || []; if (handlers.length === 0) return; await eventExecutor.executeWithErrorHandling(handlers); }, [eventExecutor] ); const setStateValue = useCallback( (path: string, value: any) => { setScopedStateValueDirect(path, value); }, [setScopedStateValueDirect] ); const contextValue = useMemo( () => ({ state: scopedState, resolveText, executeHandlers, dispatchEvent, pages, navigateToPage, setStateValue, previewMode: true, }), [dispatchEvent, executeHandlers, navigateToPage, pages, resolveText, scopedState, setStateValue] ); return {children}; };