// appends.ts import { HsonNode, NodeContent, Primitive } from "../../../core/types.js"; import { ELEM_TAG } from "../../../core/constants.js"; import { is_Node } from "../../../core/node-guards.js"; import { CREATE_NODE } from "../../../core/factories.js"; import { unwrap_root_elem } from "../../transform/utils/html-utils/unwrap-root-elem.js"; import { get_el_for_node, unlinkNode } from "../utils/node-map-helpers.js"; import { project_livetree } from "../creation/project-live-tree.js"; import { LiveTree } from "../livetree.js"; import { normalize_ix } from "../../transform/utils/json-utils/normalize-ix.js"; import { TreeSelector } from "../creation/tree-selector.js"; import { SVG_TAGS } from "../../../core/all-html-tags.js"; import { SvgTag } from "../../../types/livetree.types.js"; import { SVG_NS } from "../../transform/utils/node-utils/node-from-svg.js"; import { get_node_by_quid, get_quid } from "../quid/data-quid.js"; import { collect_subtree_nodes } from "../utils/subtree-traversal.js"; import { is_livetree_node_disposed } from "../livetree-state.js"; import { claim_node_parent, parent_for_node, release_node_parent, } from "../lifecycle/graph-ownership.js"; import { LIVETREE_BATCH_ATTACHMENT_ERROR_CODE, LIVETREE_BATCH_VALIDATION_ERROR_CODE, LiveTreeAlreadyAttachedError, LiveTreeBatchError, LiveTreeDisposedError, } from "../livetree.error.js"; import { record_livetree_materialization } from "../debug/materialization-profile.js"; import { assert_document_structural_mutation_allowed } from "../lifecycle/document-binding-state.js"; import { runtime_for_tree, type LiveTreeRuntime, } from "../runtime/livetree-runtime.js"; import { reconcile_browser_realization_children } from "../../../internal/browser-realization/browser-realization-dom.js"; /** * Append one or more Hson nodes into a target node's `_hson_elem` container * and mirror the change into the corresponding live DOM subtree. * * If the first child of `targetNode.$_content` is not an `_hson_elem` container, * this function creates the canonical container around the existing element * content before appending. This keeps append order identical in state and DOM. * * When a bound live DOM element exists for `targetNode`, the same nodes * are rendered via `create_live_tree2` and inserted into the DOM at the * corresponding position, keeping Hson and DOM in sync. * * @param targetNode - The Hson node that will receive the new children. * @param nodesToAppend - The Hson nodes to append into the `_hson_elem` container. * @param index - Optional insertion index within the `_hson_elem` content. * If provided, it is normalized via `normalize_ix` and * used for both Hson and DOM insertion; otherwise nodes * are appended to the end. */ function appendNodes( targetNode: HsonNode, nodesToAppend: HsonNode[], runtime: LiveTreeRuntime, index?: number, ): void { if (!targetNode.$_content) targetNode.$_content = []; // find or create the `_hson_elem` container let containerNode: HsonNode; const firstChild = targetNode.$_content[0]; if (firstChild && typeof firstChild === "object" && firstChild.$_tag === ELEM_TAG) { containerNode = firstChild; } else { const priorContent = targetNode.$_content; containerNode = CREATE_NODE({ $_tag: ELEM_TAG, $_content: priorContent }); install_element_container(targetNode, containerNode, priorContent); } if (!containerNode.$_content) containerNode.$_content = []; const childContent = containerNode.$_content; // --- Hson INSERTION -------------------------------------------------- if (typeof index === "number") { const insertIx = normalize_ix(index, childContent.length); childContent.splice(insertIx, 0, ...nodesToAppend); } else { childContent.push(...nodesToAppend); } record_livetree_materialization("hsonHostInsertions", nodesToAppend.length); for (const node of nodesToAppend) claim_node_parent(node, containerNode); // --- DOM SYNC -------------------------------------------------------- const liveElement = get_el_for_node(targetNode); if (!liveElement) return; reconcile_browser_realization_children(targetNode, runtime); record_livetree_materialization("domAppendOperations"); } /** * Append a single `LiveTree` branch as children of the current `LiveTree`'s node, * preserving Hson → DOM linkage. * * The source branch's root `_hson_elem` wrapper is unwrapped via `unwrap_root_elem`, * so that only its meaningful children are appended. The source branch then * "adopts" the host roots from the current tree so subsequent operations * on the branch stay connected to the same host DOM. * * @this LiveTree * @param branch - The `LiveTree` branch whose node subtree will be appended. * @param index - Optional insertion index within the `_hson_elem` container of * the target node; normalized consistently with `appendNodesToTree`. * @returns The receiver `LiveTree` (for chaining). */ type AppendTreeLike = Pick; /** * Atomically attach an ordered forest of detached LiveTree branches. * * Validation scans the existing target once and every incoming subtree once. * DOM projection is staged in one fragment before graph ownership changes. * Either every branch is attached in order, or graph/DOM/mapping changes made * by this operation are rolled back and every branch remains detached. */ export function append_branches_atomic( target: TTree, branches: readonly AppendTreeLike[], index?: number, ): TTree { assert_document_structural_mutation_allowed(target.node, "append branches"); if (branches.length === 0) return target; const runtime = runtime_for_tree(target); for (const branch of branches) { if (runtime_for_tree(branch) !== runtime) { throw new LiveTreeBatchError( LIVETREE_BATCH_VALIDATION_ERROR_CODE, "LiveTree batch contains a branch from another runtime scope.", ); } } const targetNode = target.node; const branchRoots = branches.map((branch) => { if (!can_append_branch_to_tree(target, branch)) { throw new LiveTreeBatchError( LIVETREE_BATCH_VALIDATION_ERROR_CODE, "LiveTree batch contains a branch incompatible with the target namespace.", ); } return unwrap_root_elem(branch.node); }); const roots = branchRoots.flat(); try { assert_appendable_forest(targetNode, roots, runtime); } catch (error) { if (error instanceof LiveTreeBatchError) throw error; throw new LiveTreeBatchError( LIVETREE_BATCH_VALIDATION_ERROR_CODE, "LiveTree batch validation failed; no branch was attached.", error, ); } record_livetree_materialization("batchAttachmentPasses"); record_livetree_materialization("branchesBatchAttached", branches.length); record_livetree_materialization("appendBranchCalls", branches.length); const liveElement = get_el_for_node(targetNode); const parentNs: "html" | "svg" = liveElement?.namespaceURI === SVG_NS ? "svg" : "html"; const incomingNodes = roots.flatMap((root) => [...collect_subtree_nodes(root, "pre")]); const previouslyMapped = new Set(incomingNodes.filter((node) => get_el_for_node(node) !== undefined)); const domRoots: Node[] = []; let fragment: DocumentFragment | undefined; try { if (liveElement !== undefined) { fragment = liveElement.ownerDocument.createDocumentFragment(); for (const root of roots) { const dom = project_livetree(root, parentNs, runtime, liveElement.ownerDocument); fragment.appendChild(dom); } domRoots.push(...Array.from(fragment.childNodes)); } } catch (error) { rollback_new_projection(incomingNodes, previouslyMapped); throw new LiveTreeBatchError( LIVETREE_BATCH_ATTACHMENT_ERROR_CODE, "LiveTree batch DOM projection failed; no branch was attached.", error, ); } if (!targetNode.$_content) targetNode.$_content = []; const priorContent = targetNode.$_content; const firstChild = priorContent[0]; const existingContainer = firstChild && typeof firstChild === "object" && firstChild.$_tag === ELEM_TAG ? firstChild : undefined; const containerNode = existingContainer ?? CREATE_NODE({ $_tag: ELEM_TAG, $_content: priorContent }); const childContent = containerNode.$_content ??= []; const insertIx = typeof index === "number" ? normalize_ix(index, childContent.length) : childContent.length; const hostRoot = target.hostRootNode(); let domInserted = false; try { if (liveElement !== undefined && fragment !== undefined) { const ref = liveElement.childNodes[insertIx] ?? null; liveElement.insertBefore(fragment, ref); domInserted = true; record_livetree_materialization("domAppendOperations"); } if (existingContainer === undefined) { install_element_container(targetNode, containerNode, priorContent); } childContent.splice(insertIx, 0, ...roots); for (const root of roots) claim_node_parent(root, containerNode); for (const branch of branches) branch.adoptRoots(hostRoot); record_livetree_materialization("hsonHostInsertions", roots.length); if (liveElement !== undefined) reconcile_browser_realization_children(targetNode, runtime); return target; } catch (error) { childContent.splice(insertIx, roots.length); for (const root of roots) release_node_parent(root, containerNode); if (existingContainer === undefined) { release_element_container(targetNode, containerNode, priorContent); targetNode.$_content = priorContent; } if (domInserted) for (const node of domRoots) node.parentNode?.removeChild(node); rollback_new_projection(incomingNodes, previouslyMapped); throw new LiveTreeBatchError( LIVETREE_BATCH_ATTACHMENT_ERROR_CODE, "LiveTree batch attachment failed and was rolled back.", error, ); } } function install_element_container( targetNode: HsonNode, containerNode: HsonNode, priorContent: Readonly, ): void { targetNode.$_content = [containerNode]; claim_node_parent(containerNode, targetNode); for (const child of priorContent) { if (!is_Node(child)) continue; release_node_parent(child, targetNode); claim_node_parent(child, containerNode); } } function release_element_container( targetNode: HsonNode, containerNode: HsonNode, priorContent: Readonly, ): void { for (const child of priorContent) { if (!is_Node(child)) continue; release_node_parent(child, containerNode); claim_node_parent(child, targetNode); } release_node_parent(containerNode, targetNode); } export function append_branch( this: TTree, branch: AppendTreeLike, index?: number, ): TTree { assert_document_structural_mutation_allowed(this.node, "append branch"); record_livetree_materialization("appendBranchCalls"); const targetNode = this.node; const srcNode = branch.node; const runtime = runtime_for_tree(this); if (runtime_for_tree(branch) !== runtime) { throw new Error("[LiveTree.append] cannot append a branch from another runtime scope."); } if (!can_append_branch_to_tree(this, branch)) { throw new Error( `[LiveTree.append] incompatible branch scope: cannot append <${String(unwrap_root_elem(srcNode)[0]?.$_tag ?? "?")}> to <${String(targetNode.$_tag)}>` ); } const nodesToAppend: HsonNode[] = unwrap_root_elem(srcNode); assert_appendable_nodes(targetNode, nodesToAppend, "append branch", runtime); appendNodes(targetNode, nodesToAppend, runtime, index); branch.adoptRoots(this.hostRootNode()); return this; } /** Append the exact ordered content captured by `detachContents()`. */ export function append_detached_content( target: TTree, content: NodeContent, sourceRuntime: LiveTreeRuntime, ): TTree { assert_document_structural_mutation_allowed(target.node, "append detached content"); const targetNode = target.node; const runtime = runtime_for_tree(target); if (runtime !== sourceRuntime) { throw new Error("Detached LiveTree content cannot cross runtime scopes."); } const nodes = content.filter((item): item is HsonNode => typeof item === "object" && item !== null); assert_appendable_nodes(targetNode, nodes, "append detached contents", runtime); const liveElement = get_el_for_node(targetNode); const parentNs: "html" | "svg" = liveElement?.namespaceURI === SVG_NS ? "svg" : "html"; targetNode.$_content.push(...content); for (const node of nodes) claim_node_parent(node, targetNode); if (liveElement) { reconcile_browser_realization_children(targetNode, runtime); } return target; } function assert_appendable_nodes( target: HsonNode, roots: readonly HsonNode[], operation: string, runtime: LiveTreeRuntime, ): void { const targetSubtree = new Set(collect_subtree_nodes(target, "pre")); record_livetree_materialization("appendValidationTargetNodes", targetSubtree.size); const localQuids = new Map(); for (const root of roots) { if (parent_for_node(root) || get_el_for_node(root)?.isConnected) { throw new LiveTreeAlreadyAttachedError(operation); } const subtreeNodes = collect_subtree_nodes(root, "pre"); record_livetree_materialization("appendValidationBranchNodes", subtreeNodes.length); const subtreeSet = new Set(subtreeNodes); for (const node of subtreeNodes) { if (targetSubtree.has(node)) throw new LiveTreeAlreadyAttachedError(operation); const parent = parent_for_node(node); if ((node === root && parent) || (node !== root && parent && !subtreeSet.has(parent))) { throw new LiveTreeAlreadyAttachedError(operation); } if (get_el_for_node(node)?.isConnected) { throw new LiveTreeAlreadyAttachedError(operation); } if (is_livetree_node_disposed(node)) { throw new LiveTreeDisposedError(operation, get_quid(node)); } const quid = get_quid(node); if (!quid) continue; const localOwner = localQuids.get(quid); if (localOwner && localOwner !== node) { throw new Error(`Duplicate QUID "${quid}" occurs within the appended subtree.`); } localQuids.set(quid, node); const registered = get_node_by_quid(quid, runtime); if (registered && registered !== node) { throw new Error(`Duplicate QUID "${quid}" is already registered to another node.`); } } } } function assert_appendable_forest( target: HsonNode, roots: readonly HsonNode[], runtime: LiveTreeRuntime, ): void { const targetSubtree = new Set(collect_subtree_nodes(target, "pre")); record_livetree_materialization("appendValidationTargetNodes", targetSubtree.size); const forestNodes = new Set(); const localQuids = new Map(); for (const root of roots) { if (parent_for_node(root) || get_el_for_node(root)?.isConnected) { throw new LiveTreeAlreadyAttachedError("append branch batch"); } const subtreeNodes = collect_subtree_nodes(root, "pre"); record_livetree_materialization("appendValidationBranchNodes", subtreeNodes.length); const subtreeSet = new Set(subtreeNodes); for (const node of subtreeNodes) { if (forestNodes.has(node) || targetSubtree.has(node)) { throw new LiveTreeAlreadyAttachedError("append branch batch"); } forestNodes.add(node); const parent = parent_for_node(node); if ((node === root && parent) || (node !== root && parent && !subtreeSet.has(parent))) { throw new LiveTreeAlreadyAttachedError("append branch batch"); } if (get_el_for_node(node)?.isConnected) throw new LiveTreeAlreadyAttachedError("append branch batch"); if (is_livetree_node_disposed(node)) throw new LiveTreeDisposedError("append branch batch", get_quid(node)); const quid = get_quid(node); if (!quid) continue; const localOwner = localQuids.get(quid); if (localOwner !== undefined && localOwner !== node) { throw new Error(`Duplicate QUID "${quid}" occurs within the appended batch.`); } localQuids.set(quid, node); const registered = get_node_by_quid(quid, runtime); if (registered !== undefined && registered !== node) { throw new Error(`Duplicate QUID "${quid}" is already registered to another node.`); } } } } function rollback_new_projection(nodes: readonly HsonNode[], previouslyMapped: ReadonlySet): void { for (const node of nodes) { if (previouslyMapped.has(node)) continue; get_el_for_node(node)?.remove(); unlinkNode(node); } } function is_svg_tag(tag: string): boolean { return SVG_TAGS.includes(tag as SvgTag); } function can_append_branch_to_tree(target: AppendTreeLike, branch: AppendTreeLike): boolean { const targetTag = String(target.node.$_tag); const roots = unwrap_root_elem(branch.node); const first = roots[0]; if (!first) return false; const branchRootTag = String(first.$_tag); const targetIsSvg = is_svg_tag(targetTag); const branchIsSvg = is_svg_tag(branchRootTag); // svg target: only svg-scoped roots allowed if (targetIsSvg) { return branchIsSvg; } // html target: // - html roots are fine // - svg root is fine // - bare svg children are not if (!branchIsSvg) return true; return branchRootTag === "svg"; }