@aihu/arbor
v0.1.4
Published
Reactive component tree (the rendering layer that consumes @aihu/signals).
Readme
@aihu/arbor
Aihu — agentic discovery and interaction, for human purpose.
Reactive component tree (the rendering layer that consumes @aihu/signals).
Part of the runtime core layer of the Aihu meta-framework. Shipped to the client; sized via bun run size. The runtime core is dep-free and stacks under @aihu/runtime → @aihu/router → @aihu/server → @aihu/app.
DOM materialization primitives for the aihu project. Build a tree of branch / leaf nodes, hand it to mount, and the renderer wires every reactive binding once and tears it down LIFO when the scope disposes. No JSX runtime, no virtual DOM, no scheduler queue — just direct DOM operations against the live nodes.
Status: v0 surface frozen (Phase 3). Spec: .team/phase-3/spec-arbor.md. Bundle ≤ 2 kB gzipped.
Hello mount
import { signal } from '@aihu/signals'
import { branch, leaf, mount } from '@aihu/arbor'
const [count, setCount] = signal(0)
const tree = branch('div', { class: 'counter' }, [
leaf('Count: '),
leaf([count, setCount]), // signal as text content (reactive)
branch('button', { onClick: () => setCount(c => c + 1) }, [
leaf('+1'),
]),
])
const scope = mount(tree, document.body)
// later…
scope.dispose() // removes the DOM and tears down every binding LIFOAPI
branch(tag, attrs?, children?): Branch
Constructs a branch node. tag is the element tag name, or null for a fragment (children mount directly into the parent).
branch('section', { id: 'main' }, [ leaf('hi') ])
branch(null, null, [ leaf('a'), leaf('b') ]) // fragmentleaf(value): Leaf / leaf.element(tag, attrs?): Leaf
Two shapes share one symbol:
leaf(value)— text leaf.valueisstring(static) or aSignal<string>tuple (reactive).leaf.element(tag, attrs?)— terminal element leaf for<img>,<br>,<input>,<hr>, etc.
leaf('hi') // static text
leaf([greeting, setGreeting]) // reactive text
leaf.element('img', { src: '/logo.png' })
leaf.element('hr')mount(node, host): MountScope
Materializes node into host (an Element or ShadowRoot) synchronously. By the time mount returns, every reactive binding has run once and subscribed to its signal, every static attr is applied, and every DOM node is appended.
const scope = mount(tree, document.querySelector('#app')!)
scope.dispose() // synchronous teardown, idempotent
scope.agent // sub-project #7 stub (don't use in v0)
scope.serialize() // throws ArborNotImplementedError in v0Attribute semantics
Inside attrs, each [key, value] pair is dispatched at mount time:
| Detection | Treatment |
|---|---|
| key.startsWith('on') AND value is a function | el.addEventListener(key.slice(2).toLowerCase(), value) |
| Array.isArray(value) (a Signal tuple [Read, Write]) | Wired through an effect — the DOM property/attribute tracks the signal. |
| string / number / boolean | Static. Set once at mount; never re-applied. |
Property vs attribute split: if key in el (e.g. disabled, value, className), the value is set as a DOM property; otherwise setAttribute(key, String(value)). See .team/phase-3/spec-arbor.md §2.4.
Trust boundary.
attrsis the renderer's trust boundary. The compiler is responsible for never emitting attacker-controllable keys. If you callbranch()/leaf.element()from hand-written code with user-controlled data, do not let user data flow into attribute keys — keys likeinnerHTML,srcdoc,outerHTMLare real DOM properties and the runtime will assign them directly. Allow-list known-safe keys at your boundary.
Disposal
MountScope.dispose() runs LIFO: deepest/latest effects first (so parent effects don't re-run against partially-cleaned children), then DOM root removal. Idempotent — calling twice is a no-op.
const a = mount(treeA, host)
const b = mount(treeB, host)
b.dispose() // tears down b's effects then removes its roots
a.dispose() // independentComing in v1 (today: stubs that throw)
import { when, each } from '@aihu/arbor'
when(condition, () => branch(...)) // ArborNotImplementedError in v0
each(list, item => item.id, item => branch(...)) // ArborNotImplementedError in v0The signatures are locked; the v1 reconciler will swap the bodies.
Pairing with non-@aihu/signals reactive systems
Arbor only requires the signal shape: a tuple readonly [Read<T>, Write<T>] where Read<T> = () => T. Anything that exposes that shape works. The runtime detects it via Array.isArray(value) (per the Deviation #11 invariant).
Tests
bunx vitest run packages/arborIncludes an arbor microbench (tests/bench.test.ts) that mounts 10K static-leaf nodes in JSDOM.
Install
npm install @aihu/arbor
# or
bun add @aihu/arborAuto-generated against @aihu/[email protected].
Package facts
| | |
|---|---|
| Version | 0.1.3 |
| Tier | A — Reactive runtime core — DOM materialization layer |
| Bundle size | 2.66 kB (gz) — limit 2800 B |
| Published files | 3 entries |
| License | MIT |
Auto-generated against @aihu/[email protected].
Exports
| Subpath | ESM | CJS |
|---|---|---|
| . | ./dist/index.js | — |
Auto-generated against @aihu/[email protected].
Dependencies
Dependencies:
@aihu/signals—workspace:*
Auto-generated against @aihu/[email protected].
See also
Auto-generated against @aihu/[email protected].
License
MIT — see LICENSE.
Auto-generated against @aihu/[email protected].
