Add React infrastructure

This commit is contained in:
murashit 2026-05-27 19:37:11 +09:00
parent df2976e984
commit 4dc1c2f86a
9 changed files with 217 additions and 3 deletions

View file

@ -10,6 +10,7 @@ const context = await esbuild.context({
format: "cjs",
platform: "node",
target: "es2022",
jsx: "automatic",
outfile: "main.js",
sourcemap: production ? false : "inline",
minify: production,

View file

@ -4,7 +4,7 @@ import eslintConfigPrettier from "eslint-config-prettier/flat";
import obsidianmd from "eslint-plugin-obsidianmd";
import tseslint from "typescript-eslint";
const typeScriptFiles = ["src/**/*.ts", "tests/**/*.ts"];
const typeScriptFiles = ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"];
const nodeJavaScriptFiles = ["*.mjs", "scripts/**/*.mjs"];
const typeScriptConfigFiles = ["*.config.ts"];
const lintedTypeScriptFiles = [...typeScriptFiles, ...typeScriptConfigFiles];
@ -66,7 +66,7 @@ export default defineConfig([
},
},
{
files: ["tests/**/*.ts"],
files: ["tests/**/*.{ts,tsx}"],
rules: {
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",

60
package-lock.json generated
View file

@ -8,9 +8,15 @@
"name": "codex-panel",
"version": "2.2.2",
"license": "Apache-2.0",
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^25.6.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"esbuild": "^0.28.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@ -1707,6 +1713,26 @@
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/react": {
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@ -2630,6 +2656,13 @@
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@ -6417,6 +6450,27 @@
],
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.6"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -6710,6 +6764,12 @@
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",

View file

@ -35,6 +35,8 @@
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^25.6.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"esbuild": "^0.28.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@ -47,5 +49,9 @@
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vitest": "^4.1.7"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
}
}

View file

@ -0,0 +1,39 @@
import { setIcon } from "obsidian";
import { useLayoutEffect, useRef, type ButtonHTMLAttributes, type ReactNode } from "react";
export interface ObsidianIconProps {
icon: string;
className?: string;
}
export function ObsidianIcon({ icon, className }: ObsidianIconProps): ReactNode {
const ref = useRef<HTMLSpanElement | null>(null);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
element.replaceChildren();
setIcon(element, icon);
}, [icon]);
return <span ref={ref} className={className} aria-hidden="true" />;
}
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: string;
label: string;
}
export function IconButton({ icon, label, className, children, ...props }: IconButtonProps): ReactNode {
const ref = useRef<HTMLButtonElement | null>(null);
useLayoutEffect(() => {
const button = ref.current;
if (!button || children) return;
button.replaceChildren();
setIcon(button, icon);
}, [children, icon]);
return (
<button {...props} ref={ref} className={className} aria-label={label} type={props.type ?? "button"}>
{children ? <ObsidianIcon icon={icon} /> : null}
{children}
</button>
);
}

View file

@ -0,0 +1,55 @@
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
const roots = new WeakMap<HTMLElement, Root>();
const guardedContainers = new WeakSet<HTMLElement>();
const internalMutationContainers = new WeakSet<HTMLElement>();
export function renderReactRoot(container: HTMLElement, node: ReactNode): void {
const root = rootForContainer(container);
internalMutationContainers.add(container);
try {
flushSync(() => {
root.render(node);
});
} finally {
internalMutationContainers.delete(container);
}
}
export function unmountReactRoot(container: HTMLElement | null): void {
if (!container) return;
const root = roots.get(container);
if (!root) return;
internalMutationContainers.add(container);
try {
flushSync(() => {
root.unmount();
});
} finally {
internalMutationContainers.delete(container);
roots.delete(container);
}
}
function rootForContainer(container: HTMLElement): Root {
const existing = roots.get(container);
if (existing) return existing;
guardExternalEmpty(container);
const root = createRoot(container);
roots.set(container, root);
return root;
}
function guardExternalEmpty(container: HTMLElement): void {
if (guardedContainers.has(container)) return;
guardedContainers.add(container);
const replaceChildren = container.replaceChildren.bind(container);
container.replaceChildren = (...nodes) => {
if (!internalMutationContainers.has(container)) {
unmountReactRoot(container);
}
replaceChildren(...nodes);
};
}

View file

@ -0,0 +1,28 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import { renderReactRoot, unmountReactRoot } from "../../../../src/shared/ui/react-root";
describe("React root adapter", () => {
it("reuses roots that render no host children", () => {
const parent = document.createElement("div");
renderReactRoot(parent, null);
renderReactRoot(parent, <button type="button">Ready</button>);
expect(parent.querySelector("button")?.textContent).toBe("Ready");
unmountReactRoot(parent);
});
it("recovers when a non-empty host is emptied imperatively", () => {
const parent = document.createElement("div");
renderReactRoot(parent, <button type="button">Before</button>);
parent.replaceChildren();
renderReactRoot(parent, <button type="button">After</button>);
expect(parent.querySelector("button")?.textContent).toBe("After");
unmountReactRoot(parent);
});
});

View file

@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
export interface ReactTestRoot {
render(node: ReactNode): void;
unmount(): void;
}
export function createReactTestRoot(container: HTMLElement): ReactTestRoot {
const root = createRoot(container);
return {
render(node) {
act(() => {
root.render(node);
});
},
unmount() {
act(() => {
root.unmount();
});
},
};
}

View file

@ -17,8 +17,9 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"jsx": "react-jsx",
"types": ["node", "vitest"],
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx", "vitest.config.ts"]
}