/** * Main-thread DOM applicator. * * This module receives VDomOp batches from the worker thread and applies * them to the actual DOM. It maintains a mapping from node IDs to DOM nodes * and handles bidirectional bindings and event dispatch. */ import type { CellRef, RuntimeClient } from "@commontools/runtime-client"; import { serializeEvent } from "./events.ts"; import type { DomEventMessage } from "./events.ts"; import type { VDomBatch, VDomOp } from "../vdom-ops.ts"; import { CellHandle } from "@commontools/runtime-client"; import { setPropDefault, type SetPropHandler } from "../render-utils.ts"; import { getLogger } from "@commontools/utils/logger"; const logger = getLogger("vdom-applicator", { enabled: false, level: "debug" }); /** * Reserved node ID for the container element. * Must match the value in worker/reconciler.ts. */ export const CONTAINER_NODE_ID = 0; /** * Options for creating a DOM applicator. */ export interface DomApplicatorOptions { /** The document to create elements in */ document?: Document; /** Callback when a DOM event needs to be sent back to the worker */ onEvent: (message: DomEventMessage) => void; /** RuntimeClient for creating CellHandles from CellRefs */ runtimeClient: RuntimeClient; /** Optional callback for errors */ onError?: (error: Error) => void; /** Optional custom property setter */ setProp?: SetPropHandler; } /** * DOM applicator that applies VDomOps to the real DOM. */ export class DomApplicator { private readonly nodes = new Map(); private readonly eventListeners = new Map< number, Map >(); /** Parent tracking: childId → parentId for O(1) descendant lookup */ private readonly nodeParents = new Map(); /** Children tracking: parentId → Set for O(n) descendant cleanup */ private readonly nodeChildren = new Map>(); private readonly document: Document; private readonly onEvent: (message: DomEventMessage) => void; private readonly runtimeClient: RuntimeClient; private readonly onError?: (error: Error) => void; private readonly setPropHandler: SetPropHandler; private rootNodeId: number | null = null; constructor(options: DomApplicatorOptions) { this.document = options.document ?? globalThis.document; this.onEvent = options.onEvent; this.runtimeClient = options.runtimeClient; this.onError = options.onError; this.setPropHandler = options.setProp ?? setPropDefault; } /** * Apply a batch of VDOM operations. */ applyBatch(batch: VDomBatch): void { logger.timeStart("apply-batch"); const opCount = batch.ops.length; for (const op of batch.ops) { try { this.applyOp(op); } catch (error) { this.onError?.( error instanceof Error ? error : new Error(String(error)), ); } } if (batch.rootId !== undefined) { this.rootNodeId = batch.rootId; } const elapsed = logger.timeEnd("apply-batch"); logger.debug("apply-batch", () => [ `Applied ${opCount} ops in ${elapsed?.toFixed(2)}ms`, `(${((elapsed ?? 0) / opCount).toFixed(3)}ms/op)`, `nodes=${this.nodes.size}`, { ops: batch.ops }, ]); } /** * Apply a single VDOM operation. */ private applyOp(op: VDomOp): void { switch (op.op) { case "create-element": this.createElement(op.nodeId, op.tagName); break; case "create-text": this.createText(op.nodeId, op.text); break; case "update-text": this.updateText(op.nodeId, op.text); break; case "set-prop": this.setProp(op.nodeId, op.key, op.value); break; case "remove-prop": this.removeProp(op.nodeId, op.key); break; case "set-event": this.setEvent(op.nodeId, op.eventType, op.handlerId); break; case "remove-event": this.removeEvent(op.nodeId, op.eventType); break; case "set-binding": this.setBinding(op.nodeId, op.propName, op.cellRef); break; case "insert-child": this.insertChild(op.parentId, op.childId, op.beforeId); break; case "move-child": this.moveChild(op.parentId, op.childId, op.beforeId); break; case "remove-node": this.removeNode(op.nodeId); break; case "set-attrs": this.setAttrs(op.nodeId, op.attrs); break; } } /** * Get the root DOM node. */ getRootNode(): Node | null { return this.rootNodeId !== null ? this.nodes.get(this.rootNodeId) ?? null : null; } /** * Get a DOM node by ID. */ getNode(nodeId: number): Node | undefined { return this.nodes.get(nodeId); } /** * Register the container element. * The container is where rendered content will be inserted. * Must be called before applying any batches. */ setContainer(container: HTMLElement): void { this.nodes.set(CONTAINER_NODE_ID, container); } /** * Mount the rendered tree into a parent element. * @deprecated Use setContainer instead - content is now inserted directly. */ mountInto(parent: HTMLElement, rootId: number): void { const root = this.nodes.get(rootId); if (root) { parent.appendChild(root); } } /** * Dispose of all tracked nodes and listeners. */ dispose(): void { logger.timeStart("dispose"); const nodeCount = this.nodes.size; const listenerCount = this.eventListeners.size; // Remove all event listeners (skip container) for (const [nodeId, listeners] of this.eventListeners) { if (nodeId === CONTAINER_NODE_ID) continue; const node = this.nodes.get(nodeId); if (node) { for (const [eventType, listener] of listeners) { (node as EventTarget).removeEventListener(eventType, listener); } } } this.eventListeners.clear(); // Remove all nodes except the container (it's owned by the caller) for (const [nodeId, node] of this.nodes) { if (nodeId === CONTAINER_NODE_ID) continue; if ( node.parentNode && typeof (node.parentNode as ParentNode & { removeChild?: unknown }) .removeChild === "function" ) { node.parentNode.removeChild(node); } } this.nodes.clear(); this.nodeParents.clear(); this.nodeChildren.clear(); this.rootNodeId = null; const elapsed = logger.timeEnd("dispose"); logger.debug("dispose", () => [ `Disposed ${nodeCount} nodes, ${listenerCount} listeners in ${ elapsed?.toFixed(2) }ms`, ]); } /** * Return a snapshot of internal state for debugging. * Returns live maps directly (no clone cost, fine for debug). */ getDebugInfo(): { nodeCount: number; listenerCount: number; totalListeners: number; rootNodeId: number | null; nodes: Map; nodeParents: Map; nodeChildren: Map>; } { let totalListeners = 0; for (const listeners of this.eventListeners.values()) { totalListeners += listeners.size; } return { nodeCount: this.nodes.size, listenerCount: this.eventListeners.size, totalListeners, rootNodeId: this.rootNodeId, nodes: this.nodes, nodeParents: this.nodeParents, nodeChildren: this.nodeChildren, }; } // ============== Operation Implementations ============== private createElement(nodeId: number, tagName: string): void { const element = this.document.createElement(tagName); this.nodes.set(nodeId, element); } private createText(nodeId: number, text: string): void { const textNode = this.document.createTextNode(text); this.nodes.set(nodeId, textNode); } private updateText(nodeId: number, text: string): void { const node = this.nodes.get(nodeId); if (node && node.nodeType === Node.TEXT_NODE) { node.textContent = text; } } private setProp(nodeId: number, key: string, value: unknown): void { const node = this.nodes.get(nodeId); if (!(node instanceof HTMLElement)) return; // Use the configured property setter (defaults to setPropDefault) this.setPropHandler(node, key, value); } private removeProp(nodeId: number, key: string): void { const node = this.nodes.get(nodeId); if (!(node instanceof HTMLElement)) return; if (key.startsWith("on") && key.length > 2) { this.removeEvent(nodeId, key.slice(2).toLowerCase()); } else if (key.startsWith("$") && key.length > 1) { (node as any)[key.slice(1)] = undefined; } else if (key.startsWith("data-")) { node.removeAttribute(key); } else if (key === "style") { node.removeAttribute("style"); } else { (node as any)[key] = undefined; } } private setEvent(nodeId: number, eventType: string, handlerId: number): void { const node = this.nodes.get(nodeId); if (!node) return; // Remove existing listener for this event type this.removeEvent(nodeId, eventType); // Create new listener const listener: EventListener = (event: Event) => { const serialized = serializeEvent(event); const message: DomEventMessage = { type: "dom-event", handlerId, event: serialized, nodeId, }; this.onEvent(message); }; // Track listener let listeners = this.eventListeners.get(nodeId); if (!listeners) { listeners = new Map(); this.eventListeners.set(nodeId, listeners); } listeners.set(eventType, listener); // Add to DOM (node as EventTarget).addEventListener(eventType, listener); } private removeEvent(nodeId: number, eventType: string): void { const listeners = this.eventListeners.get(nodeId); if (!listeners) return; const listener = listeners.get(eventType); if (!listener) return; const node = this.nodes.get(nodeId); if (node) { (node as EventTarget).removeEventListener(eventType, listener); } listeners.delete(eventType); } private setBinding(nodeId: number, propName: string, cellRef: CellRef): void { const node = this.nodes.get(nodeId); if (!(node instanceof HTMLElement)) return; // Create a CellHandle from the CellRef const cellHandle = new CellHandle(this.runtimeClient, cellRef); // Set the CellHandle on the element's property // Custom elements like ct-input and ct-checkbox expect this (node as any)[propName] = cellHandle; } private insertChild( parentId: number, childId: number, beforeId: number | null, ): void { const parent = this.nodes.get(parentId); const child = this.nodes.get(childId); if (!parent || !child) return; // Update parent/children tracking // Remove from old parent if any const oldParentId = this.nodeParents.get(childId); if (oldParentId !== undefined) { this.nodeChildren.get(oldParentId)?.delete(childId); } // Add to new parent this.nodeParents.set(childId, parentId); let children = this.nodeChildren.get(parentId); if (!children) { children = new Set(); this.nodeChildren.set(parentId, children); } children.add(childId); const beforeNode = beforeId !== null ? this.nodes.get(beforeId) ?? null : null; if (beforeNode && beforeNode.parentNode === parent) { // Only use insertBefore if the beforeNode is actually a child of parent parent.insertBefore(child, beforeNode); } else { // Either no beforeNode, or it's not a child of this parent - just append parent.appendChild(child); } } private moveChild( parentId: number, childId: number, beforeId: number | null, ): void { // Move is the same as insert - insertBefore handles it this.insertChild(parentId, childId, beforeId); } private removeNode(nodeId: number): void { const node = this.nodes.get(nodeId); if (!node) return; logger.timeStart("remove-node", String(nodeId)); // Recursively clean up descendants first (O(n) via parent/children tracking) const descendantCount = this.cleanupDescendants(nodeId); // Remove event listeners for this node const listeners = this.eventListeners.get(nodeId); if (listeners) { for (const [eventType, listener] of listeners) { (node as EventTarget).removeEventListener(eventType, listener); } this.eventListeners.delete(nodeId); } // Remove from DOM if (node.parentNode) { node.parentNode.removeChild(node); } // Remove from parent tracking const parentId = this.nodeParents.get(nodeId); if (parentId !== undefined) { this.nodeChildren.get(parentId)?.delete(nodeId); this.nodeParents.delete(nodeId); } this.nodeChildren.delete(nodeId); // Remove from tracking this.nodes.delete(nodeId); const elapsed = logger.timeEnd("remove-node", String(nodeId)); if (descendantCount > 0) { logger.debug("remove-node", () => [ `Removed node ${nodeId} with ${descendantCount} descendants in ${ elapsed?.toFixed(2) }ms`, ]); } } /** * Clean up tracked descendants of a node using parent/children tracking. * This is O(n) where n is the number of descendants, not O(n*m) like DOM traversal. * @returns The number of descendants cleaned up */ private cleanupDescendants(nodeId: number): number { const children = this.nodeChildren.get(nodeId); if (!children || children.size === 0) return 0; let count = 0; // Process children recursively (depth-first) for (const childId of children) { // Recurse first to clean up grandchildren count += this.cleanupDescendants(childId); // Clean up this child const childNode = this.nodes.get(childId); // Remove event listeners const listeners = this.eventListeners.get(childId); if (listeners && childNode) { for (const [eventType, listener] of listeners) { (childNode as EventTarget).removeEventListener(eventType, listener); } this.eventListeners.delete(childId); } // Remove from tracking maps this.nodes.delete(childId); this.nodeParents.delete(childId); this.nodeChildren.delete(childId); count++; } return count; } private setAttrs(nodeId: number, attrs: Record): void { for (const [key, value] of Object.entries(attrs)) { this.setProp(nodeId, key, value); } } } /** * Create a new DOM applicator. */ export function createDomApplicator( options: DomApplicatorOptions, ): DomApplicator { return new DomApplicator(options); }