aeico-view
v0.2.0
Published
DOM rendering layer for Aeico - html(), render(), Reconciler
Maintainers
Readme
aeico-view
Cursor-based DOM reconciler for the browser - framework-free, zero virtual DOM.
Works in plain HTML pages, vanilla web components, and as the rendering layer of the Aeico framework.
Provides html(), render(), tags, getReconciler(), and the Reconciler class.
How it works
aeico-view uses a cursor-based reconciler rather than a virtual DOM. On each render pass the Reconciler walks the existing child list in lock-step with the render callback. Nodes at the right position with the right tag are reused; only props that changed are written to the DOM. Nodes no longer visited are removed. No diffing tree is allocated - reconciliation happens directly against the live DOM.
Installation
npm install aeico-viewUsage
Plain HTML page
Drop it into any project - no framework, no build tool required beyond a bundler or native ES modules:
import { html, render } from 'aeico-view';
// html() accepts a callback - it is NOT a template literal tag.
const tpl = html(({ div, span, button }) => {
div({ className: 'card' }, () => {
span({ text: 'Hello world' });
button({ text: 'Click me', '@click': () => alert('hi') });
});
});
// render() applies the template to a root node.
// Repeated calls reuse the same Reconciler instance and only patch what changed.
render(tpl, document.querySelector('#app')!);Vanilla web component (no base class required)
aeico-view works with any HTMLElement subclass. Use html() + render() directly inside your component methods - there is no required lifecycle adapter or mixin:
import { html, render } from 'aeico-view';
class MyCounter extends HTMLElement {
#count = 0;
shadow = this.attachShadow({ mode: 'open' });
connectedCallback() { this.#render(); }
increment() { this.#count++; this.#render(); }
#render() {
render(
html(({ div, span, button }) => {
div(() => {
span({ text: String(this.#count) });
button({ text: '+', '@click': () => this.increment() });
});
}),
this.shadow,
);
}
}
customElements.define('my-counter', MyCounter);Each call to #render() reconciles the shadow DOM in-place: only changed text or attributes are written, and the existing element nodes are reused.
Low-level: Reconciler directly
Skip html() / render() and drive the reconciler yourself:
import { Reconciler } from 'aeico-view';
const r = new Reconciler();
const root = document.querySelector('#app')!;
function draw(count: number) {
r.build(root, () => {
r.p({ text: `Count: ${count}` });
});
}
draw(0); // initial render
draw(1); // patches only the text contentTemplate anatomy
Tag helpers
Every HTML and SVG tag is available as a method on the Reconciler.
The overloads are (props?, cb?) and (cb):
html(({ div, p, ul, li, svg, circle }) => {
div({ id: 'wrapper' }, () => {
p({ text: 'paragraph' });
ul(() => {
li({ text: 'item 1' });
li({ text: 'item 2' });
});
// SVG namespace is auto-detected from the svg tag and inherited by children.
svg({ viewBox: '0 0 100 100' }, () => {
circle({ cx: '50', cy: '50', r: '40' });
});
});
});Custom elements
camelCase property names are automatically converted to kebab-case tag names:
html(({ myWidget, benchRow }) => {
myWidget(); // yields <my-widget>
benchRow(); // yields <bench-row>
});Dynamic tag names
Use el() when the tag is determined at runtime:
html((b) => {
const tag = isHeading ? 'h1' : 'h2';
b.el(tag, { text: title });
});Text nodes
html((b) => {
b.text('plain text node');
});Fragments
fragment() creates a DocumentFragment context. Elements inside are always freshly appended (not reconciled), making it suitable for one-time subtree construction:
html((b) => {
const frag = b.fragment(() => {
b.li({ text: 'a' });
b.li({ text: 'b' });
});
myList.appendChild(frag);
});Pre-built nodes
Insert an existing node or fragment into the reconciled tree:
html((b) => {
b.div(() => {
b.node(someExternalNode);
});
});Props reference
| Prop | Type | Effect |
|---|---|---|
| text / textContent | string | Sets element.textContent |
| className / class | string or Record<string, boolean> | Sets the class attribute |
| style | Record<string, string> | Merges into inline style; supports --custom-props |
| key | string | Keyed reconciliation hint - not written to the DOM |
| @click, @input, … | EventListener | Adds event listener; old listener removed on change |
| disabled, hidden, … | true | Presence-only attribute (setAttribute(k, '')) |
| any other key | object | Assigned as a JS property |
| any other key | string \| number | Set via setAttribute |
| null \| false | - | Removes the attribute / listener |
Class object map
div({ class: { active: isActive, hidden: !isVisible } });
// yields class="active" when isActive=true, isVisible=trueStyle object
span({ style: { color: 'red', '--my-var': '42px' } });Event handlers
button({ '@click': handleClick, '@pointerenter': handleHover });Keyed lists
Add a key prop to preserve element identity across list reorders.
The reconciler will move the existing DOM node to the correct position rather than recreating it:
html(({ ul, li }) => {
ul(() => {
for (const item of items) {
li({ key: item.id, text: item.label });
}
});
});tags - destructure outside the callback
tags is a proxy that always delegates to the currently active Reconciler.
Useful when you want to destructure helpers rather than taking them from the callback parameter.
Destructure inside a render() execution - resolving tags outside one throws:
import { html, render, tags } from 'aeico-view';
render(html(() => {
const { div, span } = tags; // resolve inside the render context
div(() => {
span({ text: 'hello' });
});
}), root);getReconciler()
Returns the Reconciler that is currently executing inside a render() call.
Throws outside a render context. Useful in helper functions that need builder access without receiving it as a parameter:
import { getReconciler } from 'aeico-view';
function badge(label: string) {
const b = getReconciler();
b.span({ className: 'badge', text: label });
}
render(html(() => {
badge('new');
}), root);detached()
detached() is an engine method on Reconciler (not part of Tags), so access it via getReconciler():
import { getReconciler } from 'aeico-view';
html((b) => {
b.div(() => {
const tip = getReconciler().detached(() =>
b.span({ className: 'tooltip', text: 'built off-cursor' }),
);
// `tip` was created fresh - NOT reconciled into b.div's children and
// NOT appended anywhere. Attach it manually wherever needed.
document.body.appendChild(tip);
});
});What it does: temporarily clears the build context (parent stack + cursor), runs the callback in "fresh append" mode, then restores the previous context - even if the callback throws.
When you need it:
- Shared helper code that may run during a render pass. Normal tag calls inside a build pass advance the cursor and get reconciled against existing children; code that must not disturb the in-progress traversal wraps its calls in
detached(). - Building a subtree to attach manually. Inside
detached(), created elements have no parent - the callback's return value hands them to you for manual mounting (as shown above).
When you do not need it: event handlers like '@click'. A render pass is synchronous, so by the time a click fires the build context is already empty - builder calls there are already in fresh-append mode.
ShadowRoot support
render() accepts any Node as its root, including a ShadowRoot:
const shadow = host.attachShadow({ mode: 'open' });
render(html(({ div }) => { div({ text: 'shadow content' }); }), shadow);API summary
| Export | Signature | Description |
|---|---|---|
| html | (cb: (t: Tags) => void) => Renderable | Creates a deferred render template |
| render | (renderable: Renderable, root: Node) => void | Applies a template to a root node |
| tags | Tags (proxy) | Tag view of the active Reconciler |
| getReconciler | () => Reconciler | Returns the active Reconciler (throws if none) |
| Reconciler | class | Core cursor-based DOM reconciler (Tags superset) |
| Tags | type | Tag vocabulary passed to html() callbacks |
| TagProps | type | Prop bag accepted by all tag helpers |
| Renderable | class | Opaque wrapper returned by html() |
