import { act, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; /** Minimal host DOM for React lifecycle tests, not a browser/layout simulator. * Components may render div/section wrappers; no event dispatch, geometry or * accessibility behavior is emulated. React's reconciler/effects are real. */ class HostElement { static DOCUMENT_POSITION_DISCONNECTED = 1; static DOCUMENT_POSITION_PRECEDING = 2; static DOCUMENT_POSITION_FOLLOWING = 4; static DOCUMENT_POSITION_CONTAINS = 8; static DOCUMENT_POSITION_CONTAINED_BY = 16; readonly nodeType = 1; readonly namespaceURI = "http://www.w3.org/1999/xhtml"; readonly childNodes: HostElement[] = []; readonly style = { setProperty() {}, getPropertyValue() { return ""; }, removeProperty() { return ""; }, }; open = false; get disabled() { return this.hasAttribute("disabled"); } isConnected = true; getAttribute(name: string) { return this.attributes.get(name) ?? null; } hasAttribute(name: string) { return this.attributes.has(name); } focus() { this.ownerDocument.activeElement = this; } showModal() { this.open = true; } close() { this.open = false; } scrollIntoView() {} // Component-library checkbox clicks the hidden input with a PointerEvent. // A real checkbox then flips `checked` and emits `change`. checked = false; getAnimations() { return []; } setPointerCapture() {} releasePointerCapture() {} hasPointerCapture() { return false; } append(...nodes: unknown[]) { for (const node of nodes) { if (node instanceof HostElement) this.appendChild(node); } } dispatchEvent(event: { type?: string }) { const inputType = this.getAttribute("type") ?? (this as unknown as { type?: string }).type; if (event?.type === "click" && this.tagName === "INPUT" && inputType === "checkbox") { this.checked = !this.checked; const change = { type: "change", target: this, currentTarget: this, nativeEvent: { defaultPrevented: false }, preventDefault() {}, stopPropagation() {}, }; (this.props.onChange as ((next: unknown) => void) | undefined)?.(change); } return true; } scrollTop = 0; scrollHeight = 0; clientHeight = 0; clientWidth = 1440; offsetWidth = 1440; readonly scrollCalls: { top?: number }[] = []; scrollTo(options: { top?: number }) { this.scrollCalls.push(options); } /** No layout here: every box is the origin. Components that position from rects still run. */ getBoundingClientRect() { return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }; } /** Class selectors only (`.a` / `.a.b`); enough for components that find their scroller. */ closest(selector: string): HostElement | null { const wanted = selector.split(".").filter(Boolean); const classes = (this.getAttribute("class") ?? "").split(/\s+/); if (wanted.every(name => classes.includes(name))) return this; return this.parentNode?.closest(selector) ?? null; } get parentElement() { return this.parentNode; } private hasAssignedChildren = false; private assignedChildren: unknown = undefined; get children() { // A test or a library may replace `children`. Until that happens, the live // element list is the child nodes. if (this.hasAssignedChildren) return this.assignedChildren; return this.childNodes.filter((child) => child.nodeType === 1); } set children(value: unknown) { this.hasAssignedChildren = true; this.assignedChildren = value; } matches() { return false; } querySelector() { return null; } querySelectorAll() { return []; } getRootNode() { return this.ownerDocument; } contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); } compareDocumentPosition(other: HostElement): number { if (!other || other === this) return 0; if (this.contains(other)) return HostElement.DOCUMENT_POSITION_CONTAINED_BY | HostElement.DOCUMENT_POSITION_FOLLOWING; if (other.contains?.(this)) return HostElement.DOCUMENT_POSITION_CONTAINS | HostElement.DOCUMENT_POSITION_PRECEDING; const chain = (node: HostElement) => { const nodes: HostElement[] = []; let current: HostElement | null = node; while (current) { nodes.push(current); current = current.parentNode; } return nodes.reverse(); }; const a = chain(this); const b = chain(other); let index = 0; while (index < a.length && index < b.length && a[index] === b[index]) index += 1; const parent = a[index - 1]; if (!parent) return HostElement.DOCUMENT_POSITION_DISCONNECTED; const aIndex = parent.childNodes.indexOf(a[index] ?? this); const bIndex = parent.childNodes.indexOf(b[index] ?? other); return aIndex <= bIndex ? HostElement.DOCUMENT_POSITION_FOLLOWING : HostElement.DOCUMENT_POSITION_PRECEDING; } get text(): string { return this.textContent + this.childNodes.map(child => child.text).join(""); } get props(): Record { return (this as unknown as Record>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; } readonly attributes = new Map(); readonly dataset: Record = {}; parentNode: HostElement | null = null; textContent = ""; get nodeValue() { return this.textContent; } set nodeValue(value: string) { this.textContent = value; } constructor(readonly tagName: string, readonly ownerDocument: HostDocument) {} get nodeName() { return this.tagName; } get firstChild() { return this.childNodes[0] ?? null; } addEventListener() {} removeEventListener() {} setAttribute(name: string, value: string) { this.attributes.set(name, String(value)); } removeAttribute(name: string) { this.attributes.delete(name); } appendChild(node: HostElement) { this.childNodes.push(node); node.parentNode = this; return node; } insertBefore(node: HostElement, before: HostElement) { this.childNodes.splice(this.childNodes.indexOf(before), 0, node); node.parentNode = this; return node; } removeChild(node: HostElement) { this.childNodes.splice(this.childNodes.indexOf(node), 1); node.parentNode = null; return node; } } class HostDocument { readonly nodeType = 9; readonly body: HostElement; readonly head: HostElement; readonly documentElement: HostElement; activeElement: HostElement | null = null; constructor() { this.documentElement = new HostElement("HTML", this); this.head = new HostElement("HEAD", this); this.body = new HostElement("BODY", this); this.documentElement.appendChild(this.head); this.documentElement.appendChild(this.body); } createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; } getElementById() { return null; } // React 19 hoists