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