报告语言改成分段页签,切换后不留下上一种语言的正文。导出从右侧或底部滑出,不再显示文件大小和说明句。星历用箭头按钮按方向翻页,卡片边框不被裁掉。登录的输入、按钮和六位验证码换成组件库。星星汇聚不改。 自测:tsc 退出码 0;lint 0 error、122 个警告;相关测试 185 条里 184 通过,另 1 条是既有的 Windows 管理路径问题;英文版 10 条和删除 5 条重跑通过;build 后首页仍是 Static;首页 JS gzip-9 为 642174,相对 639609 多 0.40%。隐私标记测试通过。 Co-Authored-By: Grok (agent loop)
288 lines
11 KiB
TypeScript
288 lines
11 KiB
TypeScript
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; }
|
|
get children() { return this.childNodes.filter((child) => child.nodeType === 1); }
|
|
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<string, unknown> { return (this as unknown as Record<string, Record<string, unknown>>)[Object.keys(this).find(key => key.startsWith("__reactProps$")) ?? ""] ?? {}; }
|
|
readonly attributes = new Map<string, string>();
|
|
readonly dataset: Record<string, string> = {};
|
|
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 <style href precedence> onto the document. Base UI tabs
|
|
// emit that tag. Returning null lets React create the node and append it to head.
|
|
querySelector() { return null; }
|
|
querySelectorAll() { return []; }
|
|
getElementsByTagName() { return []; }
|
|
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<number, ReturnType<typeof setTimeout>>();
|
|
let frameId = 0;
|
|
const requestAnimationFrame = (callback: () => void) => {
|
|
const id = ++frameId;
|
|
frames.set(id, setTimeout(() => { frames.delete(id); callback(); }, 0));
|
|
return id;
|
|
};
|
|
const cancelAnimationFrame = (id: number) => { clearTimeout(frames.get(id)); frames.delete(id); };
|
|
const computedStyle = {
|
|
direction: "ltr",
|
|
overflow: "visible",
|
|
overflowX: "visible",
|
|
overflowY: "visible",
|
|
display: "block",
|
|
position: "static",
|
|
transform: "none",
|
|
translate: "none",
|
|
scale: "none",
|
|
rotate: "none",
|
|
perspective: "none",
|
|
backdropFilter: "none",
|
|
filter: "none",
|
|
willChange: "auto",
|
|
contain: "none",
|
|
};
|
|
class KeyboardEvent {}
|
|
class FocusEvent {}
|
|
class MouseEvent {}
|
|
class PointerEvent {
|
|
bubbles = false;
|
|
button = 0;
|
|
shiftKey = false;
|
|
ctrlKey = false;
|
|
altKey = false;
|
|
metaKey = false;
|
|
constructor(readonly type: string, init: Record<string, unknown> = {}) {
|
|
Object.assign(this, init);
|
|
}
|
|
}
|
|
class ResizeObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
class MutationObserver {
|
|
constructor(callback: () => void) { this.callback = callback; }
|
|
callback: () => void;
|
|
observe() {}
|
|
disconnect() {}
|
|
takeRecords() { return []; }
|
|
}
|
|
const window = {
|
|
document,
|
|
innerWidth: 1440,
|
|
setTimeout,
|
|
clearTimeout,
|
|
getComputedStyle: () => computedStyle,
|
|
matchMedia: (query: string) => ({
|
|
matches: false,
|
|
media: query,
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
addListener() {},
|
|
removeListener() {},
|
|
}),
|
|
KeyboardEvent,
|
|
FocusEvent,
|
|
MouseEvent,
|
|
PointerEvent,
|
|
ResizeObserver,
|
|
MutationObserver,
|
|
HTMLElement: HostElement,
|
|
Element: HostElement,
|
|
Node: HostElement,
|
|
HTMLIFrameElement: class {},
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
requestAnimationFrame,
|
|
cancelAnimationFrame,
|
|
};
|
|
document.defaultView = window;
|
|
// tsx's ESM parser binds its runtime to global `self` when that binding
|
|
// exists. A partial window makes `cn` fail to parse. Next still calls
|
|
// `self.setTimeout`, so `self` is the real global, which already has it.
|
|
const globals = { window, self: globalThis, document, HTMLElement: HostElement, Element: HostElement, Node: HostElement, KeyboardEvent, FocusEvent, MouseEvent, PointerEvent, ResizeObserver, MutationObserver, getComputedStyle: () => computedStyle, requestAnimationFrame, cancelAnimationFrame, 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,
|
|
document,
|
|
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<string, unknown> = {}) {
|
|
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);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|