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.