diff --git a/examples/hello-world-react/package.json b/examples/hello-world-react/package.json
new file mode 100644
index 0000000..69067ef
--- /dev/null
+++ b/examples/hello-world-react/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@examples/hello-world-react",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "tsx src/index.tsx",
+ "dev": "tsx watch src/index.tsx"
+ },
+ "dependencies": {
+ "@bomb.sh/tty": "^0.7.0",
+ "@clack/ui": "workspace:*",
+ "@clack/ui-react": "workspace:*",
+ "react": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.20.0",
+ "@types/react": "^19.2.0",
+ "tsx": "^4.19.0"
+ }
+}
diff --git a/examples/hello-world-react/src/app.tsx b/examples/hello-world-react/src/app.tsx
new file mode 100644
index 0000000..dabc851
--- /dev/null
+++ b/examples/hello-world-react/src/app.tsx
@@ -0,0 +1,44 @@
+// oxlint-disable bombshell-dev/exported-function-async -- React components render synchronously.
+import { fixed, grow, percent } from '@bomb.sh/tty';
+import { rgba } from '@clack/ui-react';
+import { type ReactElement, useState } from 'react';
+
+/** Render the interactive hello-world example. */
+export function HelloWorld(): ReactElement {
+ const [say, setSay] = useState('Hello');
+ const [to, setTo] = useState('World');
+
+ return (
+
+
+ {say}, {to}!
+
+
+
+
+ say:
+
+
+ to:
+
+
+
+ setSay(event.value)} />
+ setTo(event.value)} />
+
+
+
+ );
+}
+
+const blue = rgba(0, 0, 238);
+const cyan = rgba(0, 205, 205);
+const gray = rgba(127, 127, 127);
diff --git a/examples/hello-world-react/src/index.tsx b/examples/hello-world-react/src/index.tsx
new file mode 100644
index 0000000..6658d86
--- /dev/null
+++ b/examples/hello-world-react/src/index.tsx
@@ -0,0 +1,9 @@
+import { stdin, stdout } from 'node:process';
+import { createUI } from '@clack/ui';
+import { createRoot } from '@clack/ui-react';
+import { HelloWorld } from './app.tsx';
+
+await using ui = await createUI({ input: stdin, output: stdout });
+const root = createRoot(ui.host.element);
+root.render();
+await ui.main();
diff --git a/examples/hello-world-react/tsconfig.json b/examples/hello-world-react/tsconfig.json
new file mode 100644
index 0000000..641da4f
--- /dev/null
+++ b/examples/hello-world-react/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "@bomb.sh/tools/tsconfig.json",
+ "compilerOptions": {
+ "composite": false,
+ "declaration": false,
+ "declarationMap": false,
+ "jsx": "react-jsx",
+ "jsxImportSource": "@clack/ui-react",
+ "noEmit": true,
+ "types": ["node"]
+ }
+}
diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json
new file mode 100644
index 0000000..4bcf6f3
--- /dev/null
+++ b/packages/ui-react/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@clack/ui-react",
+ "version": "0.0.0",
+ "keywords": [
+ "bombshell",
+ "cli",
+ "react"
+ ],
+ "homepage": "https://bomb.sh/docs/ui",
+ "license": "MIT",
+ "author": {
+ "name": "Bombshell",
+ "email": "oss@bomb.sh",
+ "url": "https://bomb.sh"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/bombshell-dev/ui.git"
+ },
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.mts",
+ "import": "./dist/index.mjs"
+ },
+ "./jsx-runtime": {
+ "types": "./dist/jsx-runtime.d.mts",
+ "import": "./dist/jsx-runtime.mjs"
+ },
+ "./jsx-dev-runtime": {
+ "types": "./dist/jsx-dev-runtime.d.mts",
+ "import": "./dist/jsx-dev-runtime.mjs"
+ },
+ "./package.json": "./package.json"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "dev": "bsh dev",
+ "build": "bsh build --dts",
+ "format": "bsh format",
+ "lint": "bsh lint",
+ "test": "bsh test"
+ },
+ "dependencies": {
+ "@clack/ui": "workspace:*",
+ "react-reconciler": "^0.33.0"
+ },
+ "devDependencies": {
+ "@bomb.sh/tools": "^0.5.4",
+ "@types/node": "^22.20.0",
+ "@types/react": "^19.2.0",
+ "@types/react-reconciler": "^0.33.0",
+ "react": "^19.2.0",
+ "vitest": "^4.1.9"
+ },
+ "peerDependencies": {
+ "react": "^19.2.0"
+ },
+ "devEngines": {
+ "packageManager": {
+ "name": "pnpm",
+ "version": "^10.7.0",
+ "onFail": "error"
+ },
+ "runtime": {
+ "name": "node",
+ "version": "^22.14.0",
+ "onFail": "error"
+ }
+ },
+ "knip": {
+ "entry": [
+ "src/index.ts",
+ "src/jsx-runtime.ts",
+ "src/jsx-dev-runtime.ts"
+ ]
+ }
+}
diff --git a/packages/ui-react/src/components.ts b/packages/ui-react/src/components.ts
new file mode 100644
index 0000000..fe3ae89
--- /dev/null
+++ b/packages/ui-react/src/components.ts
@@ -0,0 +1,10 @@
+import { type FunctionComponent, createElement } from 'react';
+import type { BoxProps, TextProps } from './elements.ts';
+
+export type { BoxProps, TextProps } from './elements.ts';
+
+/** A layout container backed by the Host's `box` element. */
+export const Box: FunctionComponent = (props) => createElement('box', props);
+
+/** A styled text scope backed by the Host's `text` element. */
+export const Text: FunctionComponent = (props) => createElement('text', props);
diff --git a/packages/ui-react/src/elements.test.ts b/packages/ui-react/src/elements.test.ts
new file mode 100644
index 0000000..90207d1
--- /dev/null
+++ b/packages/ui-react/src/elements.test.ts
@@ -0,0 +1,47 @@
+import type { HostElements as CoreHostElements, InputEvent } from '@clack/ui';
+import type { ReactNode } from 'react';
+import { describe, expectTypeOf, test } from 'vitest';
+import type { JSX } from './jsx-runtime.ts';
+
+interface ReactProbeEvent {
+ readonly value: number;
+}
+
+interface ReactProbeProps {
+ count: number;
+ onprobe?: (event: ReactProbeEvent) => void;
+}
+
+declare module '@clack/ui/elements' {
+ interface HostElements {
+ '@test/react-probe': ReactProbeProps;
+ }
+}
+
+describe('React host elements', () => {
+ test('maps box and text properties from the core registry', () => {
+ expectTypeOf().toEqualTypeOf<
+ CoreHostElements['box']['layout']
+ >();
+ expectTypeOf().toEqualTypeOf<
+ CoreHostElements['text']['color']
+ >();
+ expectTypeOf().toEqualTypeOf();
+ });
+
+ test('maps lowercase host event properties to React casing', () => {
+ expectTypeOf>()
+ .parameter(0)
+ .toEqualTypeOf();
+ expectTypeOf<
+ 'oninput' extends keyof JSX.IntrinsicElements['input'] ? true : false
+ >().toEqualTypeOf();
+ });
+
+ test('derives custom elements from core registry augmentation', () => {
+ expectTypeOf().toEqualTypeOf();
+ expectTypeOf>()
+ .parameter(0)
+ .toEqualTypeOf();
+ });
+});
diff --git a/packages/ui-react/src/elements.ts b/packages/ui-react/src/elements.ts
new file mode 100644
index 0000000..d5d69dc
--- /dev/null
+++ b/packages/ui-react/src/elements.ts
@@ -0,0 +1,23 @@
+import type { HostElements as CoreHostElements } from '@clack/ui';
+import type { HostElement } from '@clack/ui/elements';
+import type { Key, ReactNode, Ref } from 'react';
+
+type ReactProperties = {
+ [Name in keyof Properties as Name extends string
+ ? Name extends `on${infer Type}`
+ ? `on${Capitalize}`
+ : Name
+ : Name]: Properties[Name];
+} & {
+ children?: ReactNode;
+ key?: Key | null;
+ ref?: Ref;
+};
+
+export type HostElements = {
+ [Name in keyof CoreHostElements]: ReactProperties;
+};
+
+export type BoxProps = HostElements['box'];
+export type InputProps = HostElements['input'];
+export type TextProps = HostElements['text'];
diff --git a/packages/ui-react/src/index.ts b/packages/ui-react/src/index.ts
new file mode 100644
index 0000000..2f42153
--- /dev/null
+++ b/packages/ui-react/src/index.ts
@@ -0,0 +1,4 @@
+export { rgba } from '@clack/ui';
+export { Box, Text } from './components.ts';
+export type { BoxProps, HostElements, InputProps, TextProps } from './elements.ts';
+export { createRoot, type Root, type RootOptions } from './root.ts';
diff --git a/packages/ui-react/src/jsx-dev-runtime.ts b/packages/ui-react/src/jsx-dev-runtime.ts
new file mode 100644
index 0000000..52e47e8
--- /dev/null
+++ b/packages/ui-react/src/jsx-dev-runtime.ts
@@ -0,0 +1,20 @@
+import type * as React from 'react';
+import type { HostElements } from './index.ts';
+
+export { Fragment, jsxDEV } from 'react/jsx-dev-runtime';
+
+export declare namespace JSX {
+ type ElementType = React.JSX.ElementType;
+ interface Element extends React.JSX.Element {}
+ interface ElementClass extends React.JSX.ElementClass {}
+ interface ElementAttributesProperty extends React.JSX.ElementAttributesProperty {}
+ interface ElementChildrenAttribute extends React.JSX.ElementChildrenAttribute {}
+ type LibraryManagedAttributes = React.JSX.LibraryManagedAttributes<
+ Component,
+ Props
+ >;
+ interface IntrinsicAttributes extends React.JSX.IntrinsicAttributes {}
+ interface IntrinsicClassAttributes extends React.JSX
+ .IntrinsicClassAttributes {}
+ interface IntrinsicElements extends HostElements {}
+}
diff --git a/packages/ui-react/src/jsx-runtime.ts b/packages/ui-react/src/jsx-runtime.ts
new file mode 100644
index 0000000..d1bc488
--- /dev/null
+++ b/packages/ui-react/src/jsx-runtime.ts
@@ -0,0 +1,20 @@
+import type * as React from 'react';
+import type { HostElements } from './index.ts';
+
+export { Fragment, jsx, jsxs } from 'react/jsx-runtime';
+
+export declare namespace JSX {
+ type ElementType = React.JSX.ElementType;
+ interface Element extends React.JSX.Element {}
+ interface ElementClass extends React.JSX.ElementClass {}
+ interface ElementAttributesProperty extends React.JSX.ElementAttributesProperty {}
+ interface ElementChildrenAttribute extends React.JSX.ElementChildrenAttribute {}
+ type LibraryManagedAttributes = React.JSX.LibraryManagedAttributes<
+ Component,
+ Props
+ >;
+ interface IntrinsicAttributes extends React.JSX.IntrinsicAttributes {}
+ interface IntrinsicClassAttributes extends React.JSX
+ .IntrinsicClassAttributes {}
+ type IntrinsicElements = HostElements;
+}
diff --git a/packages/ui-react/src/reconciler.ts b/packages/ui-react/src/reconciler.ts
new file mode 100644
index 0000000..0a373e5
--- /dev/null
+++ b/packages/ui-react/src/reconciler.ts
@@ -0,0 +1,211 @@
+/* oxlint-disable max-params -- host-config signatures are fixed by react-reconciler */
+import type { Host } from '@clack/ui';
+import type { HostElement, HostElementChild, HostLiteral } from '@clack/ui/elements';
+import type { HostEventListener, HostEventType } from '@clack/ui/events';
+import { createContext } from 'react';
+import createReconciler, { type ReactContext } from 'react-reconciler';
+import { DefaultEventPriority, NoEventPriority } from 'react-reconciler/constants.js';
+
+type Props = Record;
+type Instance = HostElement;
+type TextInstance = HostLiteral;
+type PublicInstance = HostElementChild;
+
+export interface Container {
+ element: HostElement;
+ host: Host;
+ onCommit(): void;
+}
+
+export const reconciler = createReconciler<
+ string,
+ Props,
+ Container,
+ Instance,
+ TextInstance,
+ never,
+ never,
+ never,
+ PublicInstance,
+ object,
+ never,
+ ReturnType,
+ number,
+ null
+>({
+ supportsMutation: true,
+ supportsPersistence: false,
+ supportsHydration: false,
+ isPrimaryRenderer: true,
+
+ createInstance(type, props, container) {
+ const instance = container.host.createElement(type);
+ owners.set(instance, container.host);
+ updateProperties({ host: container.host, instance, previous: {}, next: props });
+ return instance;
+ },
+ createTextInstance(content, container) {
+ const instance = container.host.createLiteral(content);
+ owners.set(instance, container.host);
+ return instance;
+ },
+
+ appendInitialChild(parent, child) {
+ owner(parent).insertBefore(parent, child);
+ },
+ appendChild(parent, child) {
+ owner(parent).insertBefore(parent, child);
+ },
+ appendChildToContainer(container, child) {
+ container.host.insertBefore(container.element, child);
+ },
+ insertBefore(parent, child, anchor) {
+ owner(parent).insertBefore(parent, child, anchor);
+ },
+ insertInContainerBefore(container, child, anchor) {
+ container.host.insertBefore(container.element, child, anchor);
+ },
+ removeChild(parent, child) {
+ owner(parent).removeChild(parent, child);
+ },
+ removeChildFromContainer(container, child) {
+ container.host.removeChild(container.element, child);
+ },
+ clearContainer(container) {
+ removeAllChildren(container.host, container.element);
+ },
+
+ commitUpdate(instance, _type, previous, next) {
+ updateProperties({ host: owner(instance), instance, previous, next });
+ },
+ commitTextUpdate(instance, _previous, next) {
+ owner(instance).setText(instance, next);
+ },
+
+ prepareForCommit: () => null,
+ resetAfterCommit: (container) => container.onCommit(),
+
+ setCurrentUpdatePriority(priority) {
+ currentUpdatePriority = priority;
+ },
+ getCurrentUpdatePriority: () => currentUpdatePriority,
+ resolveUpdatePriority: () =>
+ currentUpdatePriority !== NoEventPriority ? currentUpdatePriority : DefaultEventPriority,
+
+ supportsMicrotasks: true,
+ scheduleMicrotask: queueMicrotask,
+ scheduleTimeout: setTimeout,
+ cancelTimeout: clearTimeout,
+ noTimeout: -1,
+
+ getRootHostContext: () => hostContext,
+ getChildHostContext: () => hostContext,
+ shouldSetTextContent: () => false,
+ finalizeInitialChildren: () => false,
+ getPublicInstance: (instance) => instance,
+ preparePortalMount: () => {},
+ detachDeletedInstance: () => {},
+ getInstanceFromNode: () => null,
+ beforeActiveInstanceBlur: () => {},
+ afterActiveInstanceBlur: () => {},
+ prepareScopeUpdate: () => {},
+ getInstanceFromScope: () => null,
+ resetTextContent(instance) {
+ removeAllChildren(owner(instance), instance);
+ },
+ hideInstance: () => {},
+ unhideInstance: () => {},
+ hideTextInstance: () => {},
+ unhideTextInstance: () => {},
+ maySuspendCommit: () => false,
+ preloadInstance: () => true,
+ startSuspendingCommit: () => {},
+ suspendInstance: () => {},
+ waitForCommitToBeReady: () => null,
+ NotPendingTransition: null,
+ HostTransitionContext: createContext(null) as unknown as ReactContext,
+ resetFormInstance: () => {},
+ requestPostPaintCallback: () => {},
+ shouldAttemptEagerTransition: () => false,
+ trackSchedulerEvent: () => {},
+ resolveEventType: () => null,
+ resolveEventTimeStamp: () => -1.1,
+});
+
+const owners = new WeakMap();
+const hostContext = {};
+let currentUpdatePriority: number = NoEventPriority;
+
+class MissingHostOwnerError extends Error {
+ readonly code = 'CLACK_REACT_MISSING_HOST_OWNER';
+ readonly instance: HostElementChild;
+
+ constructor(instance: HostElementChild) {
+ super('React instance does not belong to a Host');
+ this.name = 'MissingHostOwnerError';
+ this.instance = instance;
+ }
+}
+
+function owner(instance: HostElementChild): Host {
+ const host = owners.get(instance);
+ if (!host) throw new MissingHostOwnerError(instance);
+ return host;
+}
+
+function propertyNames(props: Props): string[] {
+ return Object.keys(props).filter((name) => name !== 'children');
+}
+
+function eventType(name: string): HostEventType | undefined {
+ const first = name[2];
+ if (!name.startsWith('on') || !first || first === first.toLowerCase()) return;
+ return `${first.toLowerCase()}${name.slice(3)}` as HostEventType;
+}
+
+function eventListener(value: unknown): HostEventListener | undefined {
+ return typeof value === 'function' ? (value as HostEventListener) : undefined;
+}
+
+interface PropertyUpdate {
+ host: Host;
+ instance: HostElement;
+ name: string;
+ previous: unknown;
+ next: unknown;
+}
+
+function updateProperty({ host, instance, name, previous, next }: PropertyUpdate): void {
+ const type = eventType(name);
+ if (type) {
+ const previousListener = eventListener(previous);
+ const nextListener = eventListener(next);
+ if (previousListener) host.removeEventListener(instance, type, previousListener);
+ if (nextListener) host.addEventListener(instance, type, nextListener);
+ } else {
+ host.setProperty(instance, name, next);
+ }
+}
+
+interface PropertiesUpdate {
+ host: Host;
+ instance: HostElement;
+ previous: Props;
+ next: Props;
+}
+
+function updateProperties({ host, instance, previous, next }: PropertiesUpdate): void {
+ for (const name of propertyNames(previous)) {
+ if (!(name in next))
+ updateProperty({ host, instance, name, previous: previous[name], next: undefined });
+ }
+ for (const name of propertyNames(next)) {
+ if (!Object.is(previous[name], next[name])) {
+ updateProperty({ host, instance, name, previous: previous[name], next: next[name] });
+ }
+ }
+}
+
+function removeAllChildren(host: Host, parent: HostElement): void {
+ for (const child of parent.children.slice()) host.removeChild(parent, child);
+}
diff --git a/packages/ui-react/src/root.test.ts b/packages/ui-react/src/root.test.ts
new file mode 100644
index 0000000..3368359
--- /dev/null
+++ b/packages/ui-react/src/root.test.ts
@@ -0,0 +1,168 @@
+import { HostApi, createHost, type HostElement, type HostLiteral } from '@clack/ui';
+import type { InputEvent } from '@clack/ui';
+import type { HostEventListener } from '@clack/ui/events';
+import { createElement as h, type ReactElement, useEffect, useState } from 'react';
+import { assert, describe, expect, test, vi } from 'vitest';
+import { Box, Text, createRoot } from './index.ts';
+
+function element(child: HostElement['children'][number]): HostElement {
+ assert(child.type === 'element');
+ return child;
+}
+
+function literal(child: HostElement['children'][number]): HostLiteral {
+ assert(child.type === 'literal');
+ return child;
+}
+
+function item(key: string): ReactElement {
+ return h('item', { key, name: key });
+}
+
+function Counter(): ReactElement {
+ const [count, setCount] = useState(0);
+ useEffect(() => setCount(1), []);
+ return h(Text, {}, String(count));
+}
+
+describe('createRoot', () => {
+ test('builds and attaches a React subtree through the Host', () => {
+ const host = createHost();
+ const root = createRoot(host.element);
+
+ root.render(h(Box, { layout: { direction: 'ttb' } }, h(Text, { color: 15 }, 'hello')));
+
+ const box = element(host.element.children[0]!);
+ const text = element(box.children[0]!);
+ expect(box.name).toBe('box');
+ expect(box.properties).toEqual({ layout: { direction: 'ttb' } });
+ expect(box.node).not.toBeNull();
+ expect(text.name).toBe('text');
+ expect(text.properties).toEqual({ color: 15 });
+ expect(text.node).not.toBeNull();
+ expect(literal(text.children[0]!)).toMatchObject({ content: 'hello', parent: text });
+ });
+
+ test('mounts within the supplied attached element', () => {
+ const host = createHost();
+ const container = host.createElement('container');
+ host.insertBefore(host.element, container);
+ const root = createRoot(container);
+
+ root.render(h('probe'));
+
+ expect(root.element).toBe(container);
+ expect(host.element.children).toEqual([container]);
+ expect(element(container.children[0]!)).toMatchObject({ name: 'probe' });
+
+ root.unmount();
+
+ expect(container.children).toEqual([]);
+ expect(container.node).not.toBeNull();
+ });
+
+ test('updates and removes properties and updates text literals', () => {
+ const host = createHost();
+ const root = createRoot(host.element);
+
+ root.render(h('probe', { nullable: null, removable: 'yes' }, 'first'));
+ const probe = element(host.element.children[0]!);
+ const text = literal(probe.children[0]!);
+
+ root.render(h('probe', { nullable: null, added: 0, ignored: undefined }, 'second'));
+
+ expect(element(host.element.children[0]!)).toBe(probe);
+ expect(probe.properties).toEqual({ nullable: null, added: 0 });
+ expect(literal(probe.children[0]!)).toBe(text);
+ expect(text.content).toBe('second');
+ });
+
+ test('moves keyed children within the same parent without recreating them', () => {
+ const host = createHost();
+ const root = createRoot(host.element);
+
+ root.render(h('list', {}, item('first'), item('second'), item('third')));
+ const list = element(host.element.children[0]!);
+ const [first, second, third] = list.children;
+
+ root.render(h('list', {}, item('third'), item('first'), item('second')));
+
+ expect(list.children).toEqual([third, first, second]);
+ });
+
+ test('routes element creation through Host middleware', () => {
+ const host = createHost();
+ const createElement = vi.fn();
+ HostApi.decorate(host.root, () => ({
+ createElement(name) {
+ createElement(name);
+ return super.createElement(name);
+ },
+ }));
+ const root = createRoot(host.element);
+
+ root.render(h('custom-element'));
+
+ expect(createElement).toHaveBeenCalledWith('custom-element');
+ });
+
+ test('delivers Host events through React event properties', () => {
+ const host = createHost();
+ let listener: HostEventListener<'input'> | undefined;
+ HostApi.decorate(host.root, () => ({
+ addEventListener(target, type, current) {
+ if (type === 'input') listener = current as HostEventListener<'input'>;
+ return super.addEventListener(target, type, current);
+ },
+ removeEventListener(target, type, current) {
+ if (current === listener) listener = undefined;
+ return super.removeEventListener(target, type, current);
+ },
+ }));
+ const root = createRoot(host.element);
+ const first = vi.fn();
+ const second = vi.fn();
+
+ root.render(h('input', { onInput: first }));
+ const input = element(host.element.children[0]!);
+ const event: InputEvent = { type: 'input', target: input, value: 'hello' };
+ listener!(event);
+
+ expect(first).toHaveBeenCalledWith(event);
+ expect(input.properties).toEqual({});
+
+ root.render(h('input', { onInput: second }));
+ listener!(event);
+
+ expect(first).toHaveBeenCalledTimes(1);
+ expect(second).toHaveBeenCalledWith(event);
+ });
+
+ test('applies updates scheduled by React hooks', async () => {
+ const host = createHost();
+ const onCommit = vi.fn();
+ const root = createRoot(host.element, { onCommit });
+
+ root.render(h(Counter));
+
+ const text = element(host.element.children[0]!);
+ await vi.waitFor(() => expect(literal(text.children[0]!).content).toBe('1'));
+ expect(onCommit).toHaveBeenCalledTimes(2);
+ });
+
+ test('unmounts and detaches the React subtree', () => {
+ const host = createHost();
+ const onCommit = vi.fn();
+ const root = createRoot(host.element, { onCommit });
+ root.render(h(Box, {}, h(Text, {}, 'bye')));
+ const box = element(host.element.children[0]!);
+ const text = element(box.children[0]!);
+
+ root.unmount();
+
+ expect(host.element.children).toEqual([]);
+ expect(box.node).toBeNull();
+ expect(text.node).toBeNull();
+ expect(onCommit).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/ui-react/src/root.ts b/packages/ui-react/src/root.ts
new file mode 100644
index 0000000..f144518
--- /dev/null
+++ b/packages/ui-react/src/root.ts
@@ -0,0 +1,57 @@
+/* oxlint-disable bombshell-dev/exported-function-async -- React root creation is synchronous. */
+import { HostApi, type Host } from '@clack/ui';
+import type { HostElement } from '@clack/ui/elements';
+import type { ReactNode } from 'react';
+import { LegacyRoot } from 'react-reconciler/constants.js';
+import { type Container, reconciler } from './reconciler.ts';
+
+export interface RootOptions {
+ /** Called once after React commits a set of Host mutations. */
+ onCommit?: (host: Host) => void;
+}
+
+export interface Root {
+ readonly element: HostElement;
+ readonly host: Host;
+ /** Reconcile `node` into the Host synchronously. */
+ render(node: ReactNode): void;
+ /** Remove the React tree from the Host synchronously. */
+ unmount(): void;
+}
+
+/** Create a React root which reconciles into an attached Host element. */
+export function createRoot(element: HostElement, options: RootOptions = {}): Root {
+ const host = HostApi.at(element.node!).getHost();
+ const container: Container = {
+ element,
+ host,
+ onCommit: () => options.onCommit?.(host),
+ };
+ const fiberRoot = reconciler.createContainer(
+ container,
+ LegacyRoot,
+ null,
+ false,
+ null,
+ 'clack',
+ noop,
+ noop,
+ noop,
+ noop,
+ );
+
+ return {
+ element,
+ host,
+ render(node) {
+ reconciler.updateContainerSync(node, fiberRoot, null, null);
+ reconciler.flushSyncWork();
+ },
+ unmount() {
+ reconciler.updateContainerSync(null, fiberRoot, null, null);
+ reconciler.flushSyncWork();
+ },
+ };
+}
+
+const noop = (): void => {};
diff --git a/packages/ui-react/tsconfig.json b/packages/ui-react/tsconfig.json
new file mode 100644
index 0000000..a055ad0
--- /dev/null
+++ b/packages/ui-react/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "@bomb.sh/tools/tsconfig.json",
+ "compilerOptions": {
+ "types": ["node"]
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2995d24..82bc8cf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -16,7 +16,7 @@ importers:
devDependencies:
'@bomb.sh/tools':
specifier: ^0.6.1
- version: 0.6.1(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ version: 0.6.1(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
'@types/node':
specifier: ^22.20.0
version: 22.20.4
@@ -25,7 +25,7 @@ importers:
version: 7.0.0-dev.20260623.1
vitest:
specifier: ^4.1.9
- version: 4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ version: 4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
examples/basic:
dependencies:
@@ -78,6 +78,31 @@ importers:
specifier: ^4.19.0
version: 4.23.15
+ examples/hello-world-react:
+ dependencies:
+ '@bomb.sh/tty':
+ specifier: ^0.7.0
+ version: 0.7.0
+ '@clack/ui':
+ specifier: workspace:*
+ version: link:../../packages/ui
+ '@clack/ui-react':
+ specifier: workspace:*
+ version: link:../../packages/ui-react
+ react:
+ specifier: ^19.2.0
+ version: 19.3.0
+ devDependencies:
+ '@types/node':
+ specifier: ^22.20.0
+ version: 22.20.4
+ '@types/react':
+ specifier: ^19.2.0
+ version: 19.3.0
+ tsx:
+ specifier: ^4.19.0
+ version: 4.23.15
+
examples/hello-world-solid:
dependencies:
'@bomb.sh/tty':
@@ -126,7 +151,7 @@ importers:
devDependencies:
'@bomb.sh/tools':
specifier: ^0.5.4
- version: 0.5.6(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ version: 0.5.6(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
'@types/node':
specifier: ^22.20.0
version: 22.20.4
@@ -135,7 +160,35 @@ importers:
version: 11.0.0-beta.2
vitest:
specifier: ^4.1.9
- version: 4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ version: 4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+
+ packages/ui-react:
+ dependencies:
+ '@clack/ui':
+ specifier: workspace:*
+ version: link:../ui
+ react-reconciler:
+ specifier: ^0.33.0
+ version: 0.33.0(react@19.3.0)
+ devDependencies:
+ '@bomb.sh/tools':
+ specifier: ^0.5.4
+ version: 0.5.6(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ '@types/node':
+ specifier: ^22.20.0
+ version: 22.20.4
+ '@types/react':
+ specifier: ^19.2.0
+ version: 19.3.0
+ '@types/react-reconciler':
+ specifier: ^0.33.0
+ version: 0.33.0(@types/react@19.3.0)
+ react:
+ specifier: ^19.2.0
+ version: 19.3.0
+ vitest:
+ specifier: ^4.1.9
+ version: 4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
packages/ui-solid:
dependencies:
@@ -145,7 +198,7 @@ importers:
devDependencies:
'@bomb.sh/tools':
specifier: ^0.5.4
- version: 0.5.6(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ version: 0.5.6(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
'@types/node':
specifier: ^22.20.0
version: 22.20.4
@@ -154,7 +207,7 @@ importers:
version: 1.9.14
vitest:
specifier: ^4.1.9
- version: 4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ version: 4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
packages:
@@ -1081,6 +1134,14 @@ packages:
'@types/node@22.20.4':
resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==}
+ '@types/react-reconciler@0.33.0':
+ resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==}
+ peerDependencies:
+ '@types/react': '*'
+
+ '@types/react@19.3.0':
+ resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
+
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1':
resolution: {integrity: sha512-8AX9NwC+G6Sbh5hNLnx8YgxoRV/8BH8FQRtZ86OTtUQfESMRvwszOGTtfcC32g86O5jsEQPDHXKVSnsIzWh6lg==}
engines: {node: '>=16.20.0'}
@@ -1702,6 +1763,16 @@ packages:
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
+ react-reconciler@0.33.0:
+ resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==}
+ engines: {node: '>=0.10.0'}
+ peerDependencies:
+ react: ^19.2.0
+
+ react@19.3.0:
+ resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
+ engines: {node: '>=0.10.0'}
+
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@@ -1737,6 +1808,9 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -1875,8 +1949,8 @@ packages:
resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==}
engines: {node: '>=18.12.0'}
- vite@8.3.0:
- resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
+ vite@8.3.1:
+ resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -2204,7 +2278,7 @@ snapshots:
'@bomb.sh/args@0.3.1': {}
- '@bomb.sh/tools@0.5.6(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))':
+ '@bomb.sh/tools@0.5.6(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))':
dependencies:
'@bomb.sh/args': 0.3.1
'@humanfs/node': 0.16.8
@@ -2217,8 +2291,8 @@ snapshots:
tinyexec: 1.3.1
tsdown: 0.22.14(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.24.2)(publint@0.3.24)(tsx@4.23.15)
ultramatter: 0.0.4
- vitest: 4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
- vitest-ansi-serializer: 0.2.1(vitest@4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
+ vitest: 4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ vitest-ansi-serializer: 0.2.1(vitest@4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
transitivePeerDependencies:
- '@arethetypeswrong/core'
- '@edge-runtime/vm'
@@ -2247,7 +2321,7 @@ snapshots:
- vite-plus
- vue-tsc
- '@bomb.sh/tools@0.6.1(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))':
+ '@bomb.sh/tools@0.6.1(@types/node@22.20.4)(oxc-resolver@11.24.2)(tsx@4.23.15)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))':
dependencies:
'@bomb.sh/args': 0.3.1
'@humanfs/node': 0.16.8
@@ -2260,8 +2334,8 @@ snapshots:
tinyexec: 1.3.1
tsdown: 0.22.14(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.24.2)(publint@0.3.24)(tsx@4.23.15)
ultramatter: 0.0.4
- vitest: 4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
- vitest-ansi-serializer: 0.2.1(vitest@4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
+ vitest: 4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ vitest-ansi-serializer: 0.2.1(vitest@4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))
transitivePeerDependencies:
- '@arethetypeswrong/core'
- '@edge-runtime/vm'
@@ -2740,6 +2814,14 @@ snapshots:
dependencies:
undici-types: 6.21.0
+ '@types/react-reconciler@0.33.0(@types/react@19.3.0)':
+ dependencies:
+ '@types/react': 19.3.0
+
+ '@types/react@19.3.0':
+ dependencies:
+ csstype: 3.2.3
+
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1':
optional: true
@@ -2780,13 +2862,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
- '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))':
+ '@vitest/mocker@4.1.11(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
+ vite: 8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -3334,6 +3416,13 @@ snapshots:
quansync@1.0.0: {}
+ react-reconciler@0.33.0(react@19.3.0):
+ dependencies:
+ react: 19.3.0
+ scheduler: 0.27.0
+
+ react@19.3.0: {}
+
readdirp@3.6.0:
dependencies:
picomatch: 2.3.2
@@ -3380,6 +3469,8 @@ snapshots:
dependencies:
mri: 1.2.0
+ scheduler@0.27.0: {}
+
semver@5.7.2: {}
semver@6.3.1: {}
@@ -3484,7 +3575,7 @@ snapshots:
verkit@0.3.2: {}
- vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1):
+ vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -3499,14 +3590,14 @@ snapshots:
tsx: 4.23.15
yaml: 2.9.1
- vitest-ansi-serializer@0.2.1(vitest@4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))):
+ vitest-ansi-serializer@0.2.1(vitest@4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))):
dependencies:
- vitest: 4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ vitest: 4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
- vitest@4.1.11(@types/node@22.20.4)(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
+ vitest@4.1.11(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
dependencies:
'@vitest/expect': 4.1.11
- '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
+ '@vitest/mocker': 4.1.11(vite@8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -3523,7 +3614,7 @@ snapshots:
tinyexec: 1.3.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
- vite: 8.3.0(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
+ vite: 8.3.1(@types/node@22.20.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.4