143 lines
5.4 KiB
TypeScript
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)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|