High-level facade over the parser→render→template+dom pipeline. Block-oriented API.
Module uiSession | Source packages/front/fw/src/dom/rendering/uiSession.js | Deps uiSessionCore, uiSessionDirect, uiSessionList | Worker-safe no
uiSessionis a glue facade that composes three sibling modules:uiSessionCore(lifecycle, mount/portals),uiSessionDirect(text/attr/on/bindmutators),uiSessionList(UIListclass +list()factory). The actual pipeline consumers (parser,render,template,dom) are dependencies of the three sub-modules, not ofuiSessiondirectly.
Resolve
const uiSession = runtime.resolve('uiSession');
// Returns: function(containerName) → UISession
const ui = uiSession('myContext');
// 'myContext' must already exist (created via tpl.init)
Prerequisites
template.init must have been called before creating the session:
const tpl = runtime.resolve('template');
tpl.init('content', { to: '#app' });
const ui = uiSession('content');
API — UISession
All mutating methods return this (chainable).
ui.parse(htmlString) → ParseResult
Shortcut for parser.fromHTML(html).
const block = ui.parse('<div><h1>#{title}</h1>${slot}</div>');
ui.add(items[]) → this
Renders and inserts blocks into the context.
ui.add([{
id: 'main', // logical identifier of the block
block: parsedBlock, // ParseResult from ui.parse()
data: { title: 'Hello' } // variables to bind
}]);
Typedef: RenderItem
{
id?: string, // logical identifier (for get/remove/move)
block?: ParseResult, // result of ui.parse()
template?: ElmNode[], // alternative: direct elm-array
data?: Object|Object[], // bindings; array → iterative render (loop)
attach?: { // attach into a slot of another block
elm: string, // blockId of the parent
name: string // name of the slot ${name}
},
prepend?: boolean // insert before existing children
}
ui.append(items[]) → this
Alias for add — semantically explicit for appending.
ui.prepend(items[]) → this
Inserts blocks before existing content.
ui.prepend([{ id: 'banner', block: b, data: { msg: 'Header' } }]);
ui.hydrate(items[], opts?) → this
Adopts already-present DOM (rendered by render.toHTML server-side) instead of materialising new nodes. Walks the session container subtree, finds each [data-fw-id="<prefix>:<elm.id>"], and registers it in _map / _attach exactly as ui.add would have done.
ui.hydrate(
[
{ id: 'layout', block: layoutTpl, data: { title: 'Page' } },
{ id: 'body', attach: { elm: 'layout', name: 'body' },
block: bodyTpl, data: { txt: 'inside' } },
],
{ idPrefix: 'app' },
);
| Option | Description |
|---|---|
idPrefix |
Must match the idPrefix passed to render.toHTML. Without it, defaults to ''. |
Guarantees:
- DOM elements are reused (same references).
ui.get(blockId, lid)returns the server-rendered node. - All subsequent operations (
ui.text/attr/on/list/clear/remove/...) work normally. ui.clear(blockId)removes the block from the DOM with its usual cascade.- Throws with an explicit message if an expected
data-fw-idis missing (template ↔ SSR HTML mismatch).
v1 limitations:
- Iterate blocks (
<!-- $name -->) are not auto-reconnected to aui.list. The wrapper is adopted, but to react to the array, callui.list(parent, slot, …).sync(items)after hydrate. - No listeners are re-attached automatically — the consumer must call
ui.on(...)after hydrate (typically in the componentmounthook).
See also guide rendering-pipeline for the SSR round-trip flow.
ui.replace(blockId, items[]) → this
Clears a block and replaces it with new items.
ui.replace('main', [{ id: 'main', block: newBlock, data: newData }]);
ui.get(blockId, logicalId?) → Element | null
Retrieves a DOM node by logical identifiers.
const rootNode = ui.get('main'); // root node of the 'main' block
const h1Node = ui.get('main', 'title'); // element with HTML id 'title' in the block
Root resolution (1-arg): ui.get(blockId) first tries a logical id equal to the blockId (case where the template has an id="X" matching the caller's add({id:'X', …})). Otherwise it returns the first element of the block-map (render order: parent before children → that is the block root).
Iterative blocks (rendered via add({data: <array>})): returns null — there is no single root for N iterations. Use template.get directly to address items from a loop.
Slots vs attach — vocabulary
- Slot (
${name}in the HTML template): declares a zone in the parent template that can receive attached content. One zone, one name. - Attach (
attach: {elm, name}in aRenderItem): specifies where a child block is inserted — into the slotnameof the blockelmin the session.
Both concepts coexist: the parent template declares slots, and children attach into them via their attach.name.
ui.query(blockId, logicalId, selector) → Element | null
Searches for a CSS descendant within an element.
const btn = ui.query('panel', 'form', 'button.submit');
ui.queryAll(blockId, logicalId, selector) → NodeList | []
Searches for all CSS descendants.
const items = ui.queryAll('list', 'ul', 'li');
ui.remove(blockId, logicalId) → this
Removes an element and its children from the DOM and the session.
ui.remove('main', 'subtitle');
ui.clear(blockId?, logicalId?) → this
Overloads:
clear()→ full context resetclear(blockId)→ removes all elements of the blockclear(blockId, logicalId)→ removes the children of the element (keeps the element)
ui.clear(); // full reset
ui.clear('main'); // clears the entire 'main' block
ui.clear('main', 'ul'); // clears children of 'ul' (keeps 'ul')
ui.text(blockId, logicalId, value) → this
Updates the textContent of a logical element. No-op if not found.
ui.text('header', 'title', 'New title');
ui.attr(blockId, logicalId, name, value) → this
Updates an attribute. If value is null, false, or undefined, the attribute is removed (consistent with conditional attributes in template).
ui.attr('panel', 'btn', 'disabled', isLoading ? '' : null);
ui.on(blockId, logicalId, type, fn, name?, options?) → string | null
Registers a managed listener bound to the logical element. The listener is tracked internally and automatically removed when the block/element is removed via remove, clear, or replace. Returns the listener name (auto-generated if not provided) to allow a manual ui.off(name); returns null if the element is not found.
ui.on('viewer', 'closeBtn', 'click', () => ui.remove('viewer'));
// listener automatically removed when `ui.remove('viewer')` is called
ui.off(name) → this
Removes a managed listener by its name.
ui.exists(blockId, logicalId?) → boolean
Checks whether a block (or a logical element within a block) exists in the session.
if (ui.exists('modal')) ui.remove('modal');
ui.list(parentBlockId, slotName, options) → UIList
Creates an incremental keyed list controller mounted in the ${slotName} slot of an already-rendered block. Each item becomes its own addressable uiSession sub-block by the caller's key — so all session APIs (text, attr, on, remove, move) work per item.
const list = ui.list('parent', 'items', {
keyFn: item => item.id, // required — stable key extractor
block: ui.parse('<li>#{text}</li>'), // required — item ParseResult
eqFn: Object.is, // optional — skip update if true (default Object.is)
onMount: (key, item) => {…}, // optional — called after each mount
onUpdate: 'replace' | 'patch' | 'none' | fn, // update mode (default 'replace')
dataFn: item => bindings, // optional — DOM-bound data extraction
metaFn: item => meta, // optional — side data (not DOM)
});
ui.list is idempotent by (parentBlockId, slotName): a second call on the same pair returns the same instance, and the options may be omitted:
// First call: options required (at least keyFn + block)
ui.list('parent', 'items', { keyFn: x => x.id, block: TPL.item, onUpdate: 'patch' });
// Subsequent calls: options optional, the already-created instance is returned
const list = ui.list('parent', 'items');
Options from subsequent calls are ignored except block, which must be the same ParseResult reference (otherwise throws — the template structure cannot change during a list's lifetime). Callbacks (keyFn, dataFn, metaFn, eqFn, onUpdate, onMount, onRemove) remain those fixed at creation — they are generally inline arrows recreated on each call and must not be compared by reference. To change an option, remove the parent via ui.clear(parent) and rebuild.
When the parent block is removed via ui.clear(parentBlockId) or ui.remove(parentBlockId, …), the list is auto-disposed: any subsequent operation throws with an explicit message.
Controller API
| Method | Behaviour |
|---|---|
list.sync(items) |
Reconciles with a complete dataset. Returns {added, kept, updated, removed, reordered} (Sets of keys + boolean). Unchanged items keep their DOM node. |
list.adopt(items, { idPrefix? }) |
SSR only: adopts rows already rendered server-side (cf. render.toHTML with iterates) without DOM rebuild. Must be called on an empty list, right after ui.hydrate. See SSR section. |
list.push(item) |
Appends; throws if key already present. |
list.prepend(item) |
Inserts at head; throws on duplicate. |
list.insert(item, beforeKey?) |
Inserts before beforeKey, or appends if beforeKey is absent/unknown. |
list.upsert(item) |
Inserts or replaces; returns 'added' | 'updated' | 'unchanged'. |
list.update(key, item) |
Strict update: throws if the key is absent. Returns 'updated' | 'unchanged'. |
list.remove(key) |
Removes the item; releases DOM + managed listeners. Returns boolean. |
list.move(key, beforeKey?) |
Moves without recreating the DOM. |
list.clear() |
Clears the list. |
list.has(key) / list.get(key) |
Lookup. |
list.meta(key) |
Lookup of meta data (if metaFn provided). |
list.keys() / list.entries() |
Ordered iteration. |
list.element(key) |
Root DOM element of the item, or null. |
list.itemId(key) |
Internal uiSession block identifier of the item. |
list.attr(key, name, value) or list.attr(key, logicalId, name, value) |
Proxy of ui.attr by key. |
list.text(key, value) or list.text(key, logicalId, value) |
Proxy of ui.text by key. |
list.on(key, type, fn, name?) or list.on(key, logicalId, type, fn, name?) |
Proxy of ui.on by key. |
list.query(key, selector) / list.queryAll(key, selector) |
CSS search within an item's subtree. |
list.size |
Getter number. |
Update modes (onUpdate)
| Mode | Behaviour |
|---|---|
'replace' (default) |
clear+add of the item block — fresh DOM, managed listeners recreated. |
'patch' |
Re-binds #{var} and parameterised attrs on existing nodes via render.applyParsedElm. Stable DOM (focus, selection, transitions, listeners preserved). Automatic fallback to 'replace' if the item block contains iterates (<!-- $name -->) that cannot be patched. |
'none' |
No DOM change on update — the list updates its bookkeeping (get, meta) but the caller must apply changes via list.attr/list.text if desired. |
Function (key, oldItem, newItem, controller) => 'patched' | 'replaced' | 'unchanged' |
Custom strategy. Must return one of the three labels; used in the sync delta. |
dataFn / metaFn
ui.list(parent, slot, {
keyFn: item => item.id,
block: itemBlock,
dataFn: item => ({ label: item.label, href: '#/' + item.id }), // DOM bindings
metaFn: item => item.rawSource, // off-DOM
});
dataFn(item): if provided, its result is what is passed to the parser/render pipeline (for#{...}). Otherwise, the item itself is used. Allows keepingitemas raw business data without polluting the DOM with unbound fields.metaFn(item): if provided, its result is stored separately and accessible vialist.meta(key). No influence on the DOM. Typical use: reference to a sub-tree for recursion aftersync.
Hooks onMount / onRemove
ui.list(parent, slot, {
keyFn, block,
onMount: (key, item) => { /* item mounted */ },
onRemove: (key, item) => { /* before DOM removal — app cleanup */ },
});
onMount(key, item): called after the DOM insertion of an item (push,prepend,insert, additions viasync/upsert).onRemove(key, item): called before the item's DOM disappears (remove, removal viasync,clear). Ideal for cancelling in-flight fetches, detaching external observers, stopping animations.
Diff semantics (sync)
For each sync(items) call:
- Remove: every key present before and absent after →
ui.clear(itemId)(releases DOM + managed listeners). - Add: every new key → mount at the end (will be reordered in phase 3).
- Update: key present and
eqFn(old, new) === false→clear+addof the item block (re-render), unlessonUpdateis'patch'/'none'/a custom function — see Update modes. Otherwise, no change. - Reorder:
dom.reorderreorders existing nodes with minimal moves whenever the current key order differs fromnewKeys, or whenever phase 3 replaced at least one item (a'replace'd row is a fresh DOM node freshly mounted at the slot's end, regardless of its position in the key sequence — the key-sequence diff alone can't see that the row moved).delta.reorderedreflects whichever condition fired.
upsert(item) and update(key, item) apply the same rule for a single key: a 'replaced' outcome from _replaceItem restores the row's position via the same reorder pass, even though the key's index in the list didn't change. A 'patched' or 'unchanged' outcome never moves the node, so no reorder runs for it.
With eqFn = Object.is (default), passing the same reference in sync skips the replace and fully preserves the DOM (focus, selection, scroll, CSS transitions). With an immutable dataset where each render reconstructs objects, passing a custom eqFn ((a,b) => a.text === b.text && a.flag === b.flag) restores this gain.
Prerequisite: slot in the parent template
const parent = ui.parse('<div>${items}</div>'); // slot "items"
ui.add([{ id: 'parent', block: parent, data: {} }]);
const list = ui.list('parent', 'items', { … }); // OK
The slot can be a text slot (${name}). If the parent block has no slot named slotName, the first push/sync throws with an explicit message.
Edge cases
| Case | Behaviour |
|---|---|
keyFn returns null/undefined |
throws keyFn returned null/undefined |
Duplicate key in sync(items) or push |
throws duplicate key '…' |
remove(key) on absent key |
returns false (no-op) |
insert(item, beforeKey) with beforeKey not found |
appends (fallback) |
| Non-existent parent slot | throws slot '…' not resolved on block '…' |
Example — live filter
const all = [{id:'a',text:'Alpha'}, {id:'b',text:'Beta'}, {id:'c',text:'Gamma'}];
const list = ui.list('panel', 'rows', {
keyFn: x => x.id,
block: ui.parse('<li id="row"><a id="lnk">#{text}</a></li>'),
});
list.sync(all);
// Filter input → re-sync the visible subset.
input.addEventListener('input', () => {
const q = input.value.toLowerCase();
list.sync(all.filter(x => x.text.toLowerCase().includes(q)));
});
// → only new items create DOM; those still visible keep their node
// (stable focus on the filter input).
SSR: adopting server-side rendered rows
When the server has already rendered the list via render.toHTML(tpl, vars, { iterates: { rows: initialRows }, idPrefix }), the HTML received by the client already contains the final <li> elements. ui.hydrate adopts the parent block but does not adopt the iterate rows (their parser IDs collide between rows — this is expected). To reuse them without rebuilding:
// 1. Hydrate the parent block (the <ul>, not the <li>).
ui.hydrate(
[{ id: 'panel', block: panelTpl, data: vars }],
{ idPrefix: 'app' }
);
// 2. Create the list and pass it the initial snapshot.
const list = ui.list('panel', 'rows', {
keyFn: r => r.id,
block: rowTpl,
// Important: a content-aware eqFn; otherwise Object.is rejects subsequent
// re-fetches (new objects == "modified") and `sync` rebuilds every row.
eqFn: (a, b) => a.id === b.id && a.text === b.text,
});
list.adopt(initialRows, { idPrefix: 'app' });
// → list.size === initialRows.length, no DOM mutation,
// the SSR <li> elements are now under the list's control.
// 3. Later, incremental syncs reuse SSR nodes
// for unchanged items (focus / scroll / transitions preserved).
list.sync(await fetchRows());
Constraints:
- The sub-template (
block) must have a single root (standard case: a<li>, a<tr>). Multi-root → throws; recreate viasyncinstead. - The slot must contain exactly
items.lengthchild elements. Any client/server mismatch → throws with a clear message. list.adoptmust be called on an empty list (no priorpush). Throws otherwise.onMount(key, item)hooks are fired for each row;onEnteris intentionally skipped (the DOM is already visible — replaying the enter animation would be incoherent).
ui.move(blockId, logicalId, targetBlockId, targetLogicalId) → this
Moves an element to a new parent.
ui.move('left', 'item', 'right', 'container');
ui.onUnmount(blockId, fn) → this
Registers a pre-removal hook on a block. The hook is invoked just before the block's DOM disappears (via remove, clear(blockId), replace, or global clear()). Ideal for application cleanup tied to a block's lifecycle:
const controller = new AbortController();
ui.add([{ id: 'feed', block: feedTpl, data: {} }]);
fetch('/stream', { signal: controller.signal });
ui.onUnmount('feed', () => controller.abort());
Typical use cases:
- Cancel an in-flight
fetch/AbortController - Detach an IntersectionObserver / ResizeObserver
- Stop an active
setInterval/requestAnimationFrame - Close a WebSocket / SSE connection bound to the block
Guarantees:
- One hook per blockId — re-registering replaces the previous one.
- One-shot hook — automatically removed after invocation (no manual cleanup needed).
- Errors swallowed — any exception thrown in the hook is silently ignored. The unmount flow cannot be broken by a consumer bug.
- DOM still mounted at invocation — the hook may call
ui.get(blockId)or inspect attributes. - Passing
null/undefinedasfnremoves the hook without triggering it.
For individual list items, see ui.list(parent, slot, { onRemove }) which covers the same need per item.
Examples
Basic example
import fw from './fw/main.js';
const { runtime, domReady } = fw;
domReady.loaded(() => {
const tpl = runtime.resolve('template');
const uiSession = runtime.resolve('uiSession');
const dom = runtime.resolve('dom');
tpl.init('app', { to: '#root' });
const ui = uiSession('app');
const b = ui.parse('<div><h1>#{title}</h1><p>#{body}</p></div>');
ui.add([{ id: 'content', block: b, data: { title: 'Hello', body: 'World' } }]);
// Access the DOM
dom.style(ui.get('content', 'title'), 'color', 'blue');
});
Slots and attachment
const layout = ui.parse(`<div class="layout"><main>${'$'}main</main><aside>${'$'}sidebar</aside></div>`);
const widget = ui.parse('<p>#{text}</p>');
ui.add([{ id: 'layout', block: layout, data: {} }]);
ui.add([
{ id: 'main', attach: { elm: 'layout', name: 'main' }, block: widget, data: { text: 'Main content' } },
{ id: 'sidebar', attach: { elm: 'layout', name: 'sidebar' }, block: widget, data: { text: 'Sidebar' } }
]);
Iterative render (array data → loop)
const listBlock = ui.parse(`
<ul>
<!-- $item -->
<li class="#{cls}">#{text}</li>
<!-- item$ -->
</ul>
`);
ui.add([
{ id: 'list', block: listBlock, data: { cls: 'wrapper' } },
{
attach: { elm: 'list', name: 'item' },
block: listBlock,
data: [
{ text: 'First', cls: 'first' },
{ text: 'Second', cls: '' },
{ text: 'Third', cls: 'last' }
]
}
]);
Notes
- The logical
idinaddis optional — withoutid, the block cannot be targeted byget/remove/move. - The
logicalId → realIdtranslation is internal to the session — DOM IDs are not guaranteed stable between renders. get(blockId)withoutlogicalIdreturns the root element of the block.uiSessionwraps an existingtemplatecontext — it does not create its own context.- Implementation:
uiSession.jscomposes three internal mixins (uiSession-core.js— state and base methods,uiSession-direct.js—add/remove/text/attr/on,uiSession-list.js—UIListcontroller). These files are implementation details not documented separately.
See also
- parser, render, template
- dom — used by
query/queryAll - Guide pipeline