- README: Node.js 18+ → Node.js 18+, Deno, and Bun - package.json: add deno:true field - build-distribution.md: consolidate engine/platform sections
13 KiB
@alkdev/ujsx
Universal JSX — runtime-agnostic reactive tree primitives with TypeBox schemas.
UJSX treats JSX as an intermediate representation for multi-target rendering. The same declarative tree can target different hosts (markdown, graph structures, DOM, workflow engines) through a HostConfig adapter. No onClick, no className, no style — the tree is a pure data structure, and hosts are interpreters.
Install
npm install @alkdev/ujsx
Works in Node.js 18+, Deno, and Bun. Ships dual ESM/CJS with TypeScript declarations. No Node-specific APIs in core — runs anywhere @preact/signals-core and @alkdev/typebox do.
Quick Start
Element Construction
import { h, createRoot, createComponent } from "@alkdev/ujsx";
import type { UNode, UElement, URoot } from "@alkdev/ujsx";
const el: UElement = h("div", { class: "container" }, "hello", h("span", null, "world"));
const root: URoot = createRoot("app", h("h1", null, "Title"));
const MyComp = createComponent("MyComp", (props) => h("div", null, props.text as string));
HostConfig Rendering
import { createRoot as createHostRoot } from "@alkdev/ujsx/host";
import type { HostConfig } from "@alkdev/ujsx/host";
import { h } from "@alkdev/ujsx/h";
const host: HostConfig<string, MyInstance, MyRootCtx> = {
name: "my-host",
createRootContext: (container) => ({ container }),
createInstance: (tag, props, ctx) => /* create your instance */,
createTextInstance: (text, ctx) => /* create text instance */,
appendChild: (parent, child, ctx) => /* attach child to parent */,
};
const root = createHostRoot(host, container);
root.render(h("div", { color: "red" }, "hello"));
root.unmount();
Reactive Trees
import { ReactiveRoot, signal, reactiveComponent } from "@alkdev/ujsx/reactive";
import { h, createComponent } from "@alkdev/ujsx/h";
const r = new ReactiveRoot(h("div", null, "initial"));
r.update((prev) => h("div", null, "updated"));
const unsub = r.subscribe((node) => console.log(node));
r.render((event) => console.log(event));
unsub();
r.dispose();
JSX Configuration
Set jsxImportSource in tsconfig.json to use JSX syntax directly:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@alkdev/ujsx"
}
}
Then write JSX that produces UElement trees:
const tree = <div class="container"><span>hello</span></div>;
Bi-directional Transforms
import { TransformRegistry, childCtx, ctx as transformCtx } from "@alkdev/ujsx/transform";
import type { TransformRule, Direction } from "@alkdev/ujsx/transform";
const registry = new TransformRegistry<UNode, unknown, unknown>();
registry.register({
name: "div-to-paragraph",
direction: "ujsx→mdast" as Direction,
match: (n) => isUElement(n) && n.type === "div",
transform: (n, ctx, next) => ({
type: "paragraph",
children: (n as UElement).children.map((c, i) => next(c, childCtx(n, ctx, i))),
}),
priority: 1,
});
const result = registry.transform(myNode, transformCtx("ujsx→mdast" as Direction));
Sub-path Exports
Tree-shakeable imports — only pull in what you use:
| Sub-path | Source | Key Exports |
|---|---|---|
@alkdev/ujsx |
src/mod.ts |
All exports (barrel) |
@alkdev/ujsx/schema |
src/core/schema.ts |
UJSX, UElement, URoot, UNode, UPrimitive, isUElement, isURoot, isUPrimitive |
@alkdev/ujsx/h |
src/core/h.ts |
h, createRoot, createComponent, Fragment, jsx, jsxs, jsxDEV |
@alkdev/ujsx/reactive |
src/core/reactive.ts |
ReactiveRoot, reactiveComponent, reactiveElement, signal, computed, effect, batch |
@alkdev/ujsx/context |
src/core/context.ts |
Context, Density, Direction, RenderContext |
@alkdev/ujsx/events |
src/core/events.ts |
EventEnvelope, PubSubLike, UjsxEventMap, createPubSubEmitter, proxyEventEmitter |
@alkdev/ujsx/pointer |
src/core/pointer.ts |
ValuePointer, selectNode, setNode |
@alkdev/ujsx/host |
src/host/config.ts |
HostConfig, Root, createRoot |
@alkdev/ujsx/transform |
src/transform/registry.ts |
TransformRegistry, TransformRule, TransformContext, TransformFn, childCtx, matchesSchema, ctx |
@alkdev/ujsx/jsx-runtime |
src/core/jsx-runtime.ts |
jsx, jsxs, jsxDEV, Fragment |
Core Types
UNode (union type)
The fundamental tree node type. Every value in a UJSX tree is a UNode:
type UPrimitive = string | number | boolean | null;
type UElement = {
type: string; // tag name or component function
props: UniversalProps; // Record<string, PropValue | undefined>
children: UNode[];
key?: string; // extracted from props, not in props
};
type URoot = {
type: "root";
props: UniversalProps;
children: UNode[];
};
type UNode = UPrimitive | UElement | URoot;
PropValue
type PropValue = string | number | boolean | null | unknown[] | UNode | Record<string, unknown> | ((...args: unknown[]) => unknown);
ComponentFn & UComponent
type ComponentFn = (props: UniversalProps & { children?: UNode[] }) => UNode;
interface UComponent<P extends UniversalProps = UniversalProps> {
(props: P & { children?: UNode[] }): UNode;
displayName?: string;
targets?: string[];
}
Type Guards
function isUElement(node: UNode): node is UElement;
function isURoot(node: UNode): node is URoot;
function isUPrimitive(node: UNode): node is UPrimitive;
HostConfig Interface
The HostConfig<TTag, Instance, RootCtx> interface defines how UJSX interacts with a target platform:
interface HostConfig<TTag extends string, Instance, RootCtx> {
name: string;
createRootContext(container: unknown, options?: Record<string, unknown>, context?: Context): RootCtx;
finalizeRoot?(ctx: RootCtx): void;
createInstance(tag: TTag, props: Record<string, unknown>, ctx: RootCtx, parent?: Instance): Instance;
createTextInstance(text: string, ctx: RootCtx, parent?: Instance): Instance;
appendChild(parent: Instance, child: Instance, ctx: RootCtx): void;
insertBefore?(parent: Instance, child: Instance, before: Instance, ctx: RootCtx): void;
removeChild?(parent: Instance, child: Instance, ctx: RootCtx): void;
prepareUpdate?(instance: Instance, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): unknown | null;
commitUpdate?(instance: Instance, payload: unknown, tag: TTag, prevProps: Record<string, unknown>, nextProps: Record<string, unknown>, ctx: RootCtx): void;
emit?(type: string, id: string, payload: unknown): void;
finalizeInstance?(instance: Instance, ctx: RootCtx): void;
}
Type parameters:
TTag— string literal union constraining allowed element typesInstance— host-specific instance type (e.g.HTMLElement,Object3D)RootCtx— host-specific root context (carries refs, handles, etc.)
Required methods: name, createRootContext, createInstance, createTextInstance, appendChild
Optional methods: finalizeRoot, insertBefore, removeChild, prepareUpdate, commitUpdate, emit, finalizeInstance
Reconciler
The reconciler manages fiber tree diffing, key-based children reconciliation, and signal-driven updates:
| Export | Purpose |
|---|---|
scheduleUpdate(fiber, nextNode, host, ctx) |
Queue a fiber update (via queueMicrotask) |
flushUpdates(host, ctx) |
Process all pending updates |
reconcileProps(fiber, nextNode, host, ctx) |
Diff props using Value.Diff / Value.Equal / Value.Hash |
reconcileChildren(oldFibers, newChildren) |
Key-based classification into matched/added/removed/moves |
commitMutations(parentFiber, classification, commitCtx) |
Apply insertions, moves, removals to host instances |
commitEffects(fiber, host, ctx) |
Walk fiber tree and call commitUpdate for pending effects |
wireSignalToFiber(fiber, signalGetter, host, ctx) |
Bind a Preact signal to a fiber for automatic updates |
longestIncreasingSubsequence(arr) |
LIS algorithm for minimum-move reorder detection |
Fiber Type
interface Fiber<I> {
instance: I;
tag: string;
props: Record<string, unknown>;
key: string | undefined;
children: Fiber<I>[];
parent: Fiber<I> | null;
effect: Effect<I> | null;
signalDisposers: (() => void)[];
prevProps: Record<string, unknown> | null;
disposed: boolean;
cachedNode: UNode | null;
hash: bigint | null;
}
type Effect<I> =
| { type: "update"; payload: unknown }
| { type: "insert"; before: Fiber<I> | null }
| { type: "move"; before: Fiber<I> | null }
| { type: "remove" };
Children Reconciliation
reconcileChildren uses key-based matching with LIS (Longest Increasing Subsequence) to minimize DOM moves:
- Keyed children are matched by
keyacross old and new lists - Unkeyed children are matched positionally (left-to-right, first-available)
- The LIS of matched indices identifies children that don't need moving
- Non-LIS matched children are marked as moves
- Unmatched old children are removed; unmatched new children are added
Reactive Root
class ReactiveRoot {
constructor(initial: UNode);
get value(): ReadonlySignal<UNode>;
update(fn: (current: UNode) => UNode): void;
subscribe(listener: (node: UNode) => void): () => void;
render(emit: (event: { type: string; id: string; payload: unknown }) => void): () => void;
dispose(): void;
}
Context
class Context {
constructor(initial?: Partial<ContextValue>);
get(): ContextValue;
get signal(): ReadonlySignal<ContextValue>;
set(partial: Partial<ContextValue>): void;
subscribe(fn: (value: ContextValue) => void): () => void;
fork(overrides: Partial<ContextValue>): Context;
}
type Density = "full" | "compact" | "minimal";
type Direction = "ujsx→mdast" | "mdast→ujsx" | "ujsx→jpath" | "jpath→ujsx" | "ujsx→hast" | "hast→ujsx";
interface ContextValue { density: Density; target: string; metadata: Record<string, unknown>; }
Events
interface EventEnvelope<TType extends string = string, TPayload = unknown> {
readonly type: TType;
readonly id: string;
readonly payload: TPayload;
}
interface PubSubLike<TEventMap> {
publish<TType>(type: TType, id: string, payload: TEventMap[TType]): void;
subscribe<TType>(type: TType, id: string): AsyncIterable<EventEnvelope<TType, TEventMap[TType]>>;
}
type UjsxEventMap = {
"root.render": { childCount: number };
"root.unmount": Record<string, unknown>;
"instance.create": { kind: "text" | "element"; tag?: string; value?: string; props?: Record<string, unknown> };
"component.invoke": { type: string };
"type.call": { objectName: string; methodName: string; args: unknown[] };
"transform.apply": { ruleName: string; direction: string };
};
Tree Pointers
class ValuePointer<T> {
constructor(initial: T, path?: string[]);
get value(): T;
set value(v: T);
get reactive(): ReadonlySignal<T>;
get path(): string[];
}
function selectNode(root: UNode, path: string[]): UNode | undefined;
function setNode(root: UNode, path: string[], value: UNode): UNode;
TypeBox Runtime Validation
The UJSX export is a Type.Module from @alkdev/typebox. Use it with Value.Check for runtime validation:
import { UJSX } from "@alkdev/ujsx/schema";
import { Value } from "@alkdev/typebox/value";
const UElementSchema = UJSX.Import("UElement");
Value.Check(UElementSchema, myElement); // true | false
Available schema keys: UPrimitive, PropValue, UniversalProps, UElement, URoot, UNode.
Design Principles
- The tree is the truth. Hosts are interpreters. UJSX defines what a tree looks like, not what it means.
- HTML-agnostic core. No DOM-specific props.
onClick,className,styleare not special. - TypeBox Module IS the type registry. Runtime validation via
Value.Check, compile-time types via TypeScript. - Preact signals for reactivity. Signal-driven updates for props, reconciliation for structure.
keyas first-class field. Extracted from props, promoted toUElement.key— not stored inprops.
Dependencies
| Package | Version | Role |
|---|---|---|
@alkdev/typebox |
^0.34.49 |
Schema definition and runtime validation |
@preact/signals-core |
^1.14.1 |
Reactive primitives (signal, effect, computed, batch) |
@alkdev/pubsub |
^0.1.0 |
PubSubLike interface for event system |
Scripts
| Command | Description |
|---|---|
npm run build |
tsup production build (ESM + CJS) |
npm run build:tsc |
Type checking only (tsc --noEmit) |
npm run lint |
Type checking (tsc --noEmit) |
npm run test |
Run tests with Vitest |
npm run test:watch |
Vitest in watch mode |
npm run test:coverage |
Vitest with V8 coverage |
License
Dual-licensed under MIT or Apache 2.0 at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project shall be dual-licensed as above, without any additional terms or conditions.