@alkdev/ujsx
v0.1.0
Published
Universal JSX — runtime-agnostic reactive tree primitives with TypeBox schemas
Maintainers
Readme
@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/ujsxWorks 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 | falseAvailable 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.
