Files
Imhotep/packages/imhotep-playwright/src/react-adapter.ts
T

143 lines
5.4 KiB
TypeScript

/**
* 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<string, unknown>, ...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<void> {
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<string, unknown>)?.component ?? target.componentId
const props = (input as Record<string, unknown>)?.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<string, unknown>).__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<string, unknown>).__imhotepCreateElement as
| ((type: unknown, props?: Record<string, unknown>) => 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<string, unknown>).__imhotepComponents as
| Record<string, unknown>
| undefined
const Component = componentMap?.[args.componentRef] ?? (window as unknown as Record<string, unknown>)[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<void> {
await page.evaluate((selector: string) => {
const container = document.querySelector(selector)
if (container) {
container.innerHTML = ''
}
}, containerSelector)
},
}
}