/** * Main-thread VDOM renderer. * * This class integrates the DomApplicator with the RuntimeConnection, * handling VDomBatch notifications from the worker and sending DOM events * back to the worker. */ import type { CellRef, RuntimeClient, RuntimeConnection, VDomBatchNotification, } from "@commontools/runtime-client"; import type { DomEventMessage } from "./events.ts"; import { DomApplicator } from "./applicator.ts"; import type { SetPropHandler } from "../render-utils.ts"; import { getLogger } from "@commontools/utils/logger"; const logger = getLogger("vdom-renderer", { enabled: false, level: "debug" }); /** * Options for creating a VDomRenderer. */ export interface VDomRendererOptions { /** The RuntimeClient for creating CellHandles */ runtimeClient: RuntimeClient; /** The RuntimeConnection for IPC */ connection: RuntimeConnection; /** The document to render into */ document?: Document; /** Optional error handler */ onError?: (error: Error) => void; /** Optional custom property setter */ setProp?: SetPropHandler; } /** * VDOM renderer that bridges the worker reconciler and main-thread DOM. * * Usage: * ```ts * const renderer = new VDomRenderer({ * runtimeClient, * connection, * }); * * // Mount a cell into a container - returns a cancel function * const cancel = await renderer.render(containerElement, cellRef); * * // Later, to stop rendering: * cancel(); * ``` */ export class VDomRenderer { /** Instance counter for unique mount IDs across all renderer instances */ private static nextMountId = 1; private readonly applicator: DomApplicator; private readonly connection: RuntimeConnection; private readonly onError?: (error: Error) => void; private mountId: number | null = null; private containerElement: HTMLElement | null = null; private rootNodeId: number | null = null; private disposed = false; constructor(options: VDomRendererOptions) { this.connection = options.connection; this.onError = options.onError; // Create the DOM applicator this.applicator = new DomApplicator({ document: options.document, runtimeClient: options.runtimeClient, onEvent: (message) => this.handleDomEvent(message), onError: options.onError, setProp: options.setProp, }); // Subscribe to VDomBatch notifications this.connection.on("vdombatch", this.handleVDomBatch); } /** * Start rendering a cell into a container element. * * @param container - The DOM element to render into * @param cellRef - The cell reference to render * @returns A cancel function to stop rendering */ async render( container: HTMLElement, cellRef: CellRef, ): Promise<() => Promise> { if (this.mountId !== null) { throw new Error( "VDomRenderer already has an active mount. Call cancel first.", ); } this.containerElement = container; this.mountId = VDomRenderer.nextMountId++; // Register container so the worker can insert children directly into it this.applicator.setContainer(container); // Request the worker to start rendering logger.timeStart("mount", String(this.mountId)); try { const response = await this.connection.mountVDom(this.mountId, cellRef); this.rootNodeId = response.rootId > 0 ? response.rootId : null; const elapsed = logger.timeEnd("mount", String(this.mountId)); logger.debug("render-mount", () => [ `Mounted VDOM ${this.mountId} in ${elapsed?.toFixed(2)}ms`, `rootId=${response.rootId}`, ]); } catch (error) { // Reset state on failure so the renderer can be reused logger.timeEnd("mount", String(this.mountId)); this.mountId = null; this.containerElement = null; throw error; } // Return a cancel function return async () => { await this.stopRendering(); }; } /** * Stop rendering and clean up. */ async stopRendering(): Promise { if (this.mountId === null) { return; } const mountId = this.mountId; logger.timeStart("unmount", String(mountId)); this.mountId = null; // Request the worker to stop rendering await this.connection.unmountVDom(mountId); // Remove the root node from DOM if (this.rootNodeId !== null) { const rootNode = this.applicator.getNode(this.rootNodeId); if ( rootNode && rootNode !== this.containerElement && rootNode.parentNode ) { rootNode.parentNode.removeChild(rootNode); } this.rootNodeId = null; } this.containerElement = null; const elapsed = logger.timeEnd("unmount", String(mountId)); logger.debug("stop-rendering", () => [ `Stopped VDOM ${mountId} in ${elapsed?.toFixed(2)}ms`, ]); } /** * Dispose of the renderer and clean up all resources. */ async dispose(): Promise { if (this.disposed) return; this.disposed = true; await this.stopRendering(); this.connection.off("vdombatch", this.handleVDomBatch); this.applicator.dispose(); } /** * Get the root DOM node if available. */ getRootNode(): Node | null { return this.rootNodeId !== null ? this.applicator.getNode(this.rootNodeId) ?? null : null; } /** * Get the underlying DomApplicator for debug inspection. */ getApplicator(): DomApplicator { return this.applicator; } /** * Get the current mount ID, or null if not mounted. */ getMountId(): number | null { return this.mountId; } // ============== Private Methods ============== private handleVDomBatch = (notification: VDomBatchNotification): void => { if (this.disposed) return; // Filter for our mount ID if ( notification.mountId !== undefined && notification.mountId !== this.mountId ) { return; } logger.timeStart("batch", String(notification.batchId)); try { // Apply the batch to the DOM // Children are inserted directly into the container (CONTAINER_NODE_ID) this.applicator.applyBatch({ batchId: notification.batchId, ops: notification.ops, rootId: notification.rootId, }); // Track root node ID if provided (for cleanup) if (notification.rootId !== undefined) { this.rootNodeId = notification.rootId > 0 ? notification.rootId : null; } const elapsed = logger.timeEnd("batch", String(notification.batchId)); logger.debug("vdom-batch", () => [ `Batch ${notification.batchId}: ${notification.ops.length} ops in ${ elapsed?.toFixed(2) }ms`, ]); } catch (error) { logger.timeEnd("batch", String(notification.batchId)); this.onError?.( error instanceof Error ? error : new Error(String(error)), ); } }; private handleDomEvent(message: DomEventMessage): void { if (this.disposed || this.mountId === null) return; // Send the event to the worker via the connection this.connection.sendVDomEvent( this.mountId, message.handlerId, message.event, message.nodeId, ); } } /** * Create a new VDomRenderer. */ export function createVDomRenderer(options: VDomRendererOptions): VDomRenderer { return new VDomRenderer(options); } /** * Convenience function to render a cell into a container. * Returns a cancel function to stop rendering. * * @param container - The DOM element to render into * @param cellRef - The cell reference to render * @param options - Renderer options * @returns A cancel function */ export async function renderVDom( container: HTMLElement, cellRef: CellRef, options: VDomRendererOptions, ): Promise<() => Promise> { const renderer = createVDomRenderer(options); const cancel = await renderer.render(container, cellRef); // Return a cancel function that also disposes the renderer return async () => { await cancel(); await renderer.dispose(); }; }