/** * React renderer adapter for Imhotep Playwright. * * Mounts a React component into a container div on the page. * Uses ReactDOM.render or createRoot depending on what is available * in the injected React runtime. */ import { Page } from 'playwright' import type { SceneTarget } from 'imhotep-core/scene-target' import { RendererAdapter } from './renderers.js' export interface ReactAdapterOptions { /** * ReactDOM entry point; used for capability detection at adapter * construction time (e.g. determining React 18+ createRoot support). * The actual runtime must be exposed on the page as * `window.__imhotepReactDom` and `window.__imhotepCreateElement`. */ reactDom: { render?(element: unknown, container: Element): void createRoot?(container: Element): { render(element: unknown): void } } /** * React.createElement entry point; used alongside reactDom for capability * detection. The actual runtime must be exposed on the page as * `window.__imhotepCreateElement`. */ createElement: (type: unknown, props?: Record, ...children: unknown[]) => unknown /** Container selector or id to mount into. */ containerSelector?: string } export function createReactAdapter(options: ReactAdapterOptions): RendererAdapter { const { reactDom, createElement, containerSelector = '#__imhotep-mount' } = options return { id: 'react', async mount(page: Page, target: SceneTarget, input: unknown): Promise { if (target.kind !== 'react-component') { throw new Error(`React adapter received non-React target: ${target.kind}`) } // The component is resolved externally; we receive it as the input. // If input is a function/component reference, pass it directly. // If input is props, the component must be bundled into the page // or provided via the target's componentId. const component = (input as Record)?.component ?? target.componentId const props = (input as Record)?.props ?? input await page.evaluate( (args: { componentRef: string propsJson: string containerSelector: string hasCreateRoot: boolean }) => { const container = document.querySelector(args.containerSelector) if (!container) { throw new Error(`Mount container not found: ${args.containerSelector}`) } // Clear previous content. container.innerHTML = '' // Check for React runtime BEFORE looking for the component. // This gives clearer errors when React is missing vs component missing. const reactDom = (window as unknown as Record).__imhotepReactDom as | { render?(element: unknown, container: Element): void createRoot?(container: Element): { render(element: unknown): void } } | undefined if (!reactDom) { throw new Error( 'ReactDOM not found on window.__imhotepReactDom. ' + 'Expose ReactDOM as window.__imhotepReactDom before mounting. ' + 'Example: window.__imhotepReactDom = ReactDOM' ) } const createElement = (window as unknown as Record).__imhotepCreateElement as | ((type: unknown, props?: Record) => unknown) | undefined if (!createElement) { throw new Error( 'createElement not found on window.__imhotepCreateElement. ' + 'Expose React.createElement as window.__imhotepCreateElement before mounting. ' + 'Example: window.__imhotepCreateElement = React.createElement' ) } // Resolve the component from the global scope. // In a real setup, the page bundles components into window.__imhotepComponents. const componentMap = (window as unknown as Record).__imhotepComponents as | Record | undefined const Component = componentMap?.[args.componentRef] ?? (window as unknown as Record)[args.componentRef] if (!Component) { throw new Error( `Component "${args.componentRef}" not found on window. ` + `Expose it as window.__imhotepComponents = { "${args.componentRef}": MyComponent } ` + `or window["${args.componentRef}"] = MyComponent` ) } const parsedProps = JSON.parse(args.propsJson) const element = createElement(Component, parsedProps) if (args.hasCreateRoot && reactDom.createRoot) { const root = reactDom.createRoot(container) root.render(element) } else if (reactDom.render) { reactDom.render(element, container) } else { throw new Error('Neither createRoot nor render available on ReactDOM') } }, { componentRef: component as string, propsJson: JSON.stringify(props), containerSelector, hasCreateRoot: !!reactDom.createRoot, } ) }, async unmount(page: Page): Promise { await page.evaluate((selector: string) => { const container = document.querySelector(selector) if (container) { container.innerHTML = '' } }, containerSelector) }, } }