import process from 'node:process' import { type FileManager, onProcessExit, TreeNode } from '@kubb/fabric-core' import { ConcurrentRoot } from 'react-reconciler/constants.js' import { Root } from './components/Root.tsx' import { createNode } from './dom.ts' import type { FiberRoot } from './Renderer.ts' import { Renderer } from './Renderer.ts' import type { ComponentNode, DOMElement, FabricReactElement } from './types.ts' import { processFiles } from './utils/processFiles.ts' import { squashTextNodes } from './utils/squashTextNodes.ts' type Options = { fileManager: FileManager treeNode?: TreeNode stdout?: NodeJS.WriteStream stdin?: NodeJS.ReadStream stderr?: NodeJS.WriteStream /** * Set this to true to always see the result of the render in the console(line per render) */ debug?: boolean } export class Runtime { readonly #options: Options #isUnmounted: boolean #renderError?: Error exitPromise?: Promise readonly #container: FiberRoot readonly #rootNode: DOMElement constructor(options: Options) { this.#options = options this.#rootNode = createNode('kubb-root') this.#rootNode.onRender = this.onRender this.#rootNode.onImmediateRender = this.onRender this.#isUnmounted = false this.unmount.bind(this) // Intercept noisy React errors console.error = (data: string | Error) => { const message = typeof data === 'string' ? data : data?.message if ( message?.match(/Encountered two children with the same key/gi) || message?.match(/React will try to recreat/gi) || message?.match(/Each child in a list should have a unique/gi) || message?.match(/The above error occurred in the { this.unmount(code) }) } get fileManager() { return this.#options.fileManager } #renderPromise: Promise = Promise.resolve() resolveExitPromise: () => void = () => {} rejectExitPromise: (reason?: Error) => void = () => {} unsubscribeExit: () => void = () => {} onRender: () => Promise = () => { const previous = this.#renderPromise const task = previous .catch(() => {}) .then(async () => { if (this.#isUnmounted) { return } const files = await processFiles(this.#rootNode) await this.fileManager.add(...files) if (!this.#options?.debug && !this.#options?.stdout) { return } const output = await this.#getOutput(this.#rootNode) if (this.#options?.debug) { console.log('Rendering:\n') console.log(output) } if (this.#options?.stdout && process.env.NODE_ENV !== 'test') { this.#options.stdout.clearLine(0) this.#options.stdout.cursorTo(0) this.#options.stdout.write(output) } }) this.#renderPromise = task.catch((error) => { this.onError(error as Error) }) return this.#renderPromise } onError(error: Error): void { // Store the error to be thrown after render completes this.#renderError = error } onExit(error?: Error): void { setTimeout(() => { this.unmount(error) }, 0) } async #getOutput(node: DOMElement): Promise { const text = squashTextNodes(node) const files = this.fileManager.files if (!files.length) { return text } const values = new Set() for (const file of files) { for (const source of file.sources) { if (source?.value) { values.add(source.value) } } } return [...values].join('\n\n') } async render(node: FabricReactElement): Promise { const treeNode = this.#options.treeNode || new TreeNode({ type: 'Root', props: {} }) const props = { fileManager: this.fileManager, treeNode, onExit: this.onExit.bind(this), onError: this.onError.bind(this), } treeNode.data.props = props const element = {node} Renderer.updateContainerSync(element, this.#container, null, null) Renderer.flushSyncWork() await this.#renderPromise // Throw any errors that occurred during rendering if (this.#renderError) { const error = this.#renderError this.#renderError = undefined throw error } } async renderToString(node: FabricReactElement): Promise { const treeNode = this.#options.treeNode || new TreeNode({ type: 'Root', props: {} }) const props = { fileManager: this.fileManager, treeNode, onExit: this.onExit.bind(this), onError: this.onError.bind(this), } treeNode.data.props = props const element = {node} Renderer.updateContainerSync(element, this.#container, null, null) Renderer.flushSyncWork() await this.#renderPromise this.fileManager.clear() // Throw any errors that occurred during rendering if (this.#renderError) { const error = this.#renderError this.#renderError = undefined throw error } return this.#getOutput(this.#rootNode) } unmount(error?: Error | number | null): void { if (this.#isUnmounted) { return } if (this.#options?.debug) { console.log('Unmount', error) } this.onRender() this.unsubscribeExit() this.#isUnmounted = true Renderer.updateContainerSync(null, this.#container, null, null) if (error instanceof Error) { this.rejectExitPromise(error) return } this.resolveExitPromise() } async waitUntilExit(): Promise { if (!this.exitPromise) { this.exitPromise = new Promise((resolve, reject) => { this.resolveExitPromise = resolve this.rejectExitPromise = reject }) } return this.exitPromise } }