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 { readonly nodeType = 1; readonly namespaceURI = "http://www.w3.org/1999/xhtml"; readonly childNodes: HostElement[] = []; readonly style = {}; 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; } contains(node: HostElement | null): boolean { return node === this || this.childNodes.some(child => child.contains(node)); } 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(); 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; activeElement: HostElement | null = null; createTextNode(text: string) { const node = new HostElement("#text", this); node.textContent = text; return node; } getElementById() { return null; } defaultView: unknown; addEventListener() {} removeEventListener() {} createElement(tag: string) { return new HostElement(tag.toUpperCase(), this); } createElementNS(_namespace: string, tag: string) { return this.createElement(tag); } } export function createClientLifecycleHarness() { const document = new HostDocument(); const frames = new Map>(); let frameId = 0; const window = { document, innerWidth: 1440, setTimeout, clearTimeout, getComputedStyle: () => ({ direction: "ltr" }), HTMLElement: HostElement, HTMLIFrameElement: class {}, addEventListener() {}, removeEventListener() {}, requestAnimationFrame(callback: () => void) { const id = ++frameId; frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0)); return id; }, cancelAnimationFrame(id: number) { clearTimeout(frames.get(id)); frames.delete(id); }, }; document.defaultView = window; const globals = { window, self: window, document, HTMLElement: HostElement, Element: HostElement, IS_REACT_ACT_ENVIRONMENT: true }; const originals = Object.fromEntries(Object.keys(globals).map((key) => [ key, Object.getOwnPropertyDescriptor(globalThis, key), ])); for (const [key, value] of Object.entries(globals)) { Object.defineProperty(globalThis, key, { value, writable: true, configurable: true }); } const errors: unknown[] = []; const container = document.createElement("div"); const root = createRoot(container as unknown as HTMLElement, { onUncaughtError: (error) => { errors.push(error); }, }); function elements(node = container): HostElement[] { return [node, ...node.childNodes.flatMap(child => elements(child))]; } return { errors, elements, container, // Invoke the mounted host's current React handler inside act. This exercises // real component state/effects, not browser bubbling, native dialog or layout. async event(node: HostElement, name = "onClick", extra: Record = {}) { await act(async () => { const handler = node.props[name] as ((event: unknown) => void) | undefined; handler?.({ target: node, currentTarget: node, preventDefault() {}, stopPropagation() {}, nativeEvent: { preventDefault() {} }, ...extra }); }); }, async render(node: ReactNode) { await act(async () => { root.render(node); }); }, async update(action: () => void) { await act(async () => { action(); }); }, async idle() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); }, async close() { try { await act(async () => { root.unmount(); }); } finally { for (const timer of frames.values()) clearTimeout(timer); for (const [key, descriptor] of Object.entries(originals)) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } } }, }; }