glm-5.1 fbf13ed444 fix: update platform support docs for cross-runtime compatibility
- README: Node.js 18+ → Node.js 18+, Deno, and Bun
- package.json: add deno:true field
- build-distribution.md: consolidate engine/platform sections
2026-05-19 07:15:45 +00:00
2026-05-18 14:17:33 +00:00

@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 types
  • Instance — 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 key across 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

  1. The tree is the truth. Hosts are interpreters. UJSX defines what a tree looks like, not what it means.
  2. HTML-agnostic core. No DOM-specific props. onClick, className, style are not special.
  3. TypeBox Module IS the type registry. Runtime validation via Value.Check, compile-time types via TypeScript.
  4. Preact signals for reactivity. Signal-driven updates for props, reconciliation for structure.
  5. key as first-class field. Extracted from props, promoted to UElement.key — not stored in props.

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.

Description
No description provided
Readme 618 KiB
Languages
TypeScript 100%