ui

js/ui.ts

fino:ui — host-neutral component construction and rendering.

fino:ui is the portable core for JSX-style Fino interfaces. It owns VNode construction, function components, keyed reconciliation, and the render programs that drive them. It re-exports the reactive primitives from fino:signals for compatibility, but the signal kernel itself is shared runtime infrastructure. This module does not know about the DOM, HTML, terminal cells, input devices, or styling rules. Renderers such as fino:tty/tui and fino:ui/html provide those host details.

Design

Components are plain synchronous functions from props to a tree. They hold no hidden state and perform no asynchronous work: state lives in explicit Signal objects the component reads, so the same component renders the same way wherever it runs.

What varies is the render program around it, along two independent axes. A Sink decides what a committed tree becomes — HTML text, a terminal frame, a portable JSON descriptor, or mutations against a HostAdapter. The choice of renderStatic() or createRoot() decides the lifetime: one pass and done, or re-render for as long as the tree's signals keep changing.

Asynchronous state is therefore never a component concern. It reaches a tree by resolving into a signal, which re-renders whatever root is watching. fino:ui/realm builds the third option on that: a component rendered in a child realm publishes a tree per revision and completes when the realm's event loop drains, which turns async data loading into static output without either side changing.

Function components stay as functions in the constructed tree until a sink lowers it for a named render target. Targets declare the primitive node names they consume and may register lowerings for components or host element names they do not own. Registrations are stack-safe disposable handles: the most recent active value wins, and disposing it restores the preceding value.

/** @jsxImportSource fino:ui *\/
import { createRoot, createSignal, renderStatic } from 'fino:ui';
import { htmlSink } from 'fino:ui/html';

const count = createSignal(0);

function Counter() {
  return <label>Count: {count.get()}</label>;
}

const once = renderStatic(Counter, htmlSink());
const live = createRoot(Counter, htmlSink());
count.set(1);
live.dispose();

Types

type Child = VNode | string | number | boolean | null | undefined | Child[]

Primitive child value accepted by h().

Numbers are stringified, nested arrays are flattened, and null, undefined, and boolean placeholders are ignored during normalization.

type Component<P = Record<string, unknown>> = ( props: P & { children?: NormalizedChild[] }, ) => VNode

Function component accepted by h().

Components receive normalized props plus an optional normalized children array. They return a concrete VNode; components do not keep hidden hook state.

type VNodeType = string | typeof Fragment | Component<any>

Host-neutral element or component type accepted by h().

Strings are host element names, Fragment groups children without a host node, and functions are components — stored on the node and invoked later, by whichever render target lowers the tree.

type RenderTargetName = string

Name of a render target, used as the second key into the lowering registry.

Targets are open: 'tui' and 'html' ship here, but a name is just a string, so a target defined outside this framework participates on equal terms.

type NormalizedChild = VNode | string

Child value after normalization.

A normalized child is either a VNode or text. Empty placeholders and nested arrays have already been removed.

type Props = Record<string, unknown>

Props object stored on a VNode after key and children are removed.

Interfaces

interface VNode {

Host-neutral virtual node produced by h() and the JSX runtime.

type is either a host element name or a renderer-specific component output type. props never includes key or children; children is already flattened and does not contain null, undefined, or boolean placeholders.

Properties

type: string | Component<any>

Host element name, 'fragment' for fragment VNodes, or the component function itself — components are stored, not invoked, so a render target can substitute its own lowering for them.

props: Props

Host props with key and children removed.

children: NormalizedChild[]

Flattened child nodes and text.

key: string | number | null

Optional reconciliation key copied from the original props.

interface RenderTargetRegistration {

A reversible registration in the render-target registry.

Readonly Properties

readonly disposed: boolean

Whether this registration has already been removed.

Methods

dispose(): void

Remove only this registration, restoring the next-most-recent value.

interface HostAdapter<Node, Root> {

Host adapter consumed by createRenderer().

Hosts own the concrete node representation. The renderer calls beginUpdate and endUpdate once around each render() call when those hooks are provided.

Contract

createNode() and createText() allocate host nodes. insertChild(), moveChild(), and removeChild() mutate child order under a parent or root. updateNode() replaces host props for an existing element, and setText() updates an existing text node.

Methods

createNode(type: string, props: Props): Node

Create a host element node for type and props.

createText(text: string): Node

Create a host text node.

updateNode(node: Node, props: Props): void

Replace or patch props on an existing host element node.

setText(node: Node, text: string): void

Update an existing host text node.

insertChild(parent: Node | Root, child: Node, index: number): void

Insert child under parent at index.

moveChild(parent: Node | Root, child: Node, index: number): void

Move an existing child under parent to index.

removeChild(parent: Node | Root, child: Node): void

Remove child from parent.

beginUpdate?(): void

Optional hook called before each render pass.

endUpdate?(): void

Optional hook called after each render pass, including failed passes.

interface Sink<Out> {

Destination for a rendered tree.

A sink is the whole of what a host contributes to a render program: it turns one complete VNode tree into whatever that host cares about, and releases any resources it holds when the render program ends. commit() is called once per render pass and its return value becomes the root's output.

Sinks come in two shapes. A snapshot sink is total and stateless — HTML text, a terminal frame, a portable JSON descriptor. An incremental sink keeps a mounted tree and mutates a host node graph; wrap a HostAdapter with hostSink() to get one.

Methods

commit(tree: VNode): Out

Turn one complete tree into this host's output.

dispose?(): void

Release host resources when the render program ends.

interface Root<Out> {

Handle for a continuously rendered tree.

The root re-renders whenever a signal read during the previous pass changes, so output always reflects the latest committed tree.

Readonly Properties

readonly output: Out

Output of the most recent commit.

Methods

subscribe(subscriber: (output: Out) => void): () => void

Observe every subsequent commit.

The callback is not called for the render that already happened; read output for that. The returned function removes the subscription.

dispose(): void

Stop re-rendering and dispose the sink. Safe to call more than once.

Constants

const Fragment

Fragment marker used by JSX to group children without adding a host node.

h(Fragment, null, ...) returns the normalized children to its parent rather than creating a renderer-visible node.

Functions

function h(type: VNodeType, props: Props | null, ...children: Child[]): VNode

Construct a host-neutral VNode.

key is copied out of props and stored on the VNode for reconciliation. children from props and variadic children are merged, flattened, and stripped of empty placeholders.

Function components are not invoked here. The function is stored as the node's type and runs later, during lowerTree(), so that a render target gets the chance to substitute its own lowering for that component first — see mapRenderTargetLowering(). Calling a component directly still works and simply bypasses the registry.

import { h } from 'fino:ui';

const button = h('button', { key: 'save', disabled: true }, 'Save');

function defineRenderTarget( name: RenderTargetName, options: { primitives?: Iterable<string> } = {}, ): RenderTargetRegistration

Declare a render target and the node names it consumes directly.

primitives is the target's floor: the names it paints itself, below which no further lowering happens. Omit it to accept any node name, which suits a target whose vocabulary is open-ended — HTML tags, for instance.

import { defineRenderTarget } from 'fino:ui';

const target = defineRenderTarget('canvas', {
  primitives: ['canvas:rect', 'canvas:text'],
});
// Dispose temporary or replaceable targets when their lifetime ends.
target.dispose();

function mapRenderTargetLowering<P>( type: Component<P> | string, target: RenderTargetName, lowering: Component<P>, ): RenderTargetRegistration

Map a component or host element name to a target-specific lowering.

This is the open half of the render model. A component's own function is its default behaviour; registering a lowering for a target replaces that behaviour when the tree is lowered for that target, and leaves every other target alone. Registration is deliberately not the component author's privilege — a render target can lower components it did not write and cannot modify, which is what lets a target be added without editing the catalog.

A string key matches a host element name, so a target can catch elements generically ('article', 'strong') rather than specialising every component that emits them. The last active registration for a pair wins. Dispose the returned handle to restore the previous lowering; this makes temporary overrides explicit and allows independent integrations to clean up without deleting one another's registrations.

import { mapRenderTargetLowering } from 'fino:ui';

using checkbox = mapRenderTargetLowering(Checkbox, 'tui', TuiCheckbox);
using article = mapRenderTargetLowering('article', 'tui', TuiArticle);

function renderTargetLowering( type: string | Component<any>, target: RenderTargetName, ): Component<any> | undefined

The lowering registered for type on target, if any.

function nameComponent(component: Component<any>, name: string): RenderTargetRegistration

Give a component a stable name for boundaries that carry names, not code.

A portable tree identifies a node by string, so a component crossing a realm or an SSE stream needs a name its receiver can route. fn.name is the default and is usually enough; register an explicit, namespaced name when a component must survive a boundary and its bare function name could collide. Dispose the returned registration to restore the previous name.

function componentName(type: string | Component<any>): string

The name a node's type is known by: the string itself, or the component's name.

function lowerTree(node: VNode, target: RenderTargetName): VNode

Lower a tree until nothing is left but the target's own primitives.

Each node resolves to the lowering registered for it on target, or to the component function itself when none is registered, and the result is lowered again — so a component may compose other components and the chain resolves to a fixpoint. A node whose type is already one of the target's primitives passes through with its children lowered.

The node's key transplants onto whatever it lowered to, so reconciliation sees the identity the source tree declared. Subtrees that did not change are returned by reference, which is what lets a host reconciler skip them. An undeclared target throws RenderTargetError before any component runs.

function createRenderer<Node, Root>(host: HostAdapter<Node, Root>)

Create a renderer for a concrete host adapter.

The renderer keeps one mounted tree per root object. Re-rendering the same root reconciles by element type and child keys, producing host updates inside one update batch.

Children without explicit keys are matched by position. Text nodes are keyed by their index, while VNodes prefer their key and fall back to index.

import { createRenderer, h } from 'fino:ui';

const renderer = createRenderer(host);
renderer.render(h('label', null, 'Ready'), root);

function renderStatic<Out>(element: () => VNode, sink: Sink<Out>): Out

Render one tree, commit it, and dispose the sink.

This is the one-shot half of the render model: exactly one pass, no subscriptions, no way to publish a revision. Signal reads are ordinary, so a component can render whatever state already holds; a signal write during the pass throws StaticRenderError, because the committed output could never reflect it.

Only synchronous writes are caught. Components are synchronous by construction, so state that arrives later belongs to a render program with somewhere to publish it — createRoot(), or a realm rendered to completion.

import { h, renderStatic } from 'fino:ui';
import { htmlSink } from 'fino:ui/html';

const html = renderStatic(() => h('main', null, 'Ready'), htmlSink());

function createRoot<Out>(element: () => VNode, sink: Sink<Out>): Root<Out>

Render continuously until disposed.

The first pass runs immediately. Every signal read during a pass becomes a dependency, so later writes re-render and commit again; dependencies are re-tracked on each pass. Use batch() to coalesce a burst of writes into one commit.

import { createRoot, createSignal, h } from 'fino:ui';
import { htmlSink } from 'fino:ui/html';

const name = createSignal('world');
const root = createRoot(() => h('p', null, `hello ${name.get()}`), htmlSink());
root.subscribe((html) => console.log(html));
name.set('fino');
root.dispose();

function hostSink<Node, RootNode>( host: HostAdapter<Node, RootNode>, root: RootNode, target?: RenderTargetName, ): Sink<void>

Adapt a HostAdapter and its root object into a Sink.

The returned sink reconciles each committed tree against the previously mounted one, so a host that owns mutable nodes participates in the same render programs as a snapshot host.

Pass target when the tree still contains components: the sink lowers for that target before reconciling, so the host only ever sees its own primitives. Omit it when the caller has already lowered.

import { createRoot, hostSink } from 'fino:ui';

const root = createRoot(App, hostSink(domHost, document.body, 'dom'));

Classes

class RenderTargetError extends Error {

A tree reached a node the target has no lowering for.

The message names both the node and the target, because the fix is always one of two registrations: a lowering for that component, or a lowering for the host element it produced.

Constructors

constructor(message: string)

class StaticRenderError extends Error {

Signal write attempted during a one-shot render.

renderStatic() has no way to publish a second tree, so a component that mutates state during its pass has produced output that does not match its own state. Render in a realm, or with createRoot(), when state must change.

Constructors

constructor((message = 'Cannot set a signal during a static render'))