Dom.js
The complete source of src/Dom.js, exactly as it ships, in reading order. Every line is present; the file is split at its own section banners.
Dom.js - the DOM target: the lexical element scope and the reconciler
// ============================================================================
// Dom.js - the DOM target: the lexical element scope and the reconciler
// ============================================================================
//
// Built on Reactive.js. Dom(body) is a re-entrant ELEMENT SCOPE: each call defines the
// DOM primitives (Dom.Text / Dom.Attribute / Dom.Event.* / Dom.Tag / Dom.Loader) anchored
// to THAT activation, runs body, gathers what those primitives emitted, and RETURNS the
// gathered tree as { content, attributes, events }. Channels are lexical (closure
// semantics, doc 14 sec 6): a primitive emits into the innermost enclosing Dom scope,
// exactly the way a closure variable resolves to its nearest binding. Because the
// primitives are re-registered on every Dom call, the short names always name the current
// scope's primitives, so a UI implementation can call Dom.Tag / Dom.Text from anywhere and
// have its nodes land in the scope it is running inside.
//
// Dom.Tag builds one element by opening a NESTED Dom scope for its body: the child scope
// gathers that element's own content / attributes / events, and Tag emits a single element
// node up into the enclosing scope. Children therefore nest by explicit recursion, never by
// relaying a node up the dynamic call stack. Dom(body) returns the top-level node list; the
// program's forward callback returns it and Reactive.Mount hands it to the 'content' driver.
//
// Node shapes emitted on 'content':
// { kind:'element', key, tag, attributes:[{name,value}], properties:[{name,value}], events:[...], content:[...], locked }
// { kind:'text', key, text, locked }
//
// attributes vs properties: an attribute is markup (setAttribute) and is right for static
// descriptors (class, type, min); a property is a live JS field on the element (el.value /
// el.checked / el.selected) and is what a CONTROLLED form control needs - once the user has
// typed into an <input>, its `value` attribute stops tracking, but the `value` property still
// reflects and can be driven from the bound state.
//
// Dom.Loader wraps any subtree: it opens a child scope, and if anything in it is locked it
// shows a spinner overlay; crucially its own emitted node is NOT locked, so the loading
// state stops there and does not propagate to the whole page.
//
// Verbose console logging in the driver is intentional - these drivers are exercised by the
// test pages under testing/, where seeing the run/update sequence matters.
// ----------------------------------------------------------------------------
;(function () {
// Read the native value behind a binding (or a plain value). Does not touch the
// reactive calculate machinery - this is driver-facing, outside a Calculate.
function domNative(value) {
if (value != null && typeof value === 'object' && value.optionalGettable !== undefined) {
return value.optionalGettable == null ? undefined : value.optionalGettable.value
}
return value
}
function anyLocked(list) {
for (const item of list) {
if (item.locked === true) {
return true
}
}
return false
}
// ---- the element scope ----------------------------------------------------
// Dom(body): a fresh lexical element scope. Its inner primitives are the ONLY things that
// may emit into this scope's gathers; they exist for the duration of this call and capture
// it by closure. Returns the gathered tree, so a caller can nest a subtree by value.
Reactive.Function('Dom', () => {
const body = Reactive.Slot()
// A text node child of the innermost element.
Reactive.Function('Text', () => {
const text = Reactive.Argument(Reactive.Any)
const key = Reactive.StableId()
Reactive.Emit('content', { kind: 'text', key: key, text: domNative(text), locked: Reactive.IsLocked(text) })
})
// An attribute of the innermost element (gathered by the enclosing Tag's child scope).
Reactive.Function('Attribute', () => {
const name = Reactive.Argument(Text)
const value = Reactive.Argument(Reactive.Any)
Reactive.Emit('attributes', { name: name, value: domNative(value), locked: Reactive.IsLocked(value) })
})
// A live DOM PROPERTY of the innermost element (el.value / el.checked / el.selected): set
// as a JS property each render, not an attribute, so a controlled form control keeps
// reflecting the bound value even after the user has typed. A locked (loading) value locks
// the element, exactly as a locked attribute does.
Reactive.Function('Property', () => {
const name = Reactive.Argument(Text)
const value = Reactive.Argument(Reactive.Any)
Reactive.Emit('properties', { name: name, value: domNative(value), locked: Reactive.IsLocked(value) })
})
// Events of the innermost element. The event descriptor carries the settable BINDING itself,
// never its id - the reconciler derives the id when it wires the event. A control is disabled
// when its target either cannot be set (a read-only value) or is write-locked (a reverse
// process owns it), so read-only and settable values can be bound to a control fearlessly.
Reactive.Module('Event', () => {
// Set - on `event`, set `settable` to a fixed `value`.
Reactive.Function('Set', () => {
const event = Reactive.Argument(Text)
const settable = Reactive.Argument(Reactive.Any)
const value = Reactive.Argument(Reactive.Any)
const disabled = !Reactive.CanSet(settable) || Reactive.IsWriteLocked(settable)
Reactive.Emit('events', { kind: 'set', event: event, settable: settable, value: domNative(value), disabled: disabled })
})
// Value - on `event`, set `settable` to the value the DOM event carries (input).
Reactive.Function('Value', () => {
const event = Reactive.Argument(Text)
const settable = Reactive.Argument(Reactive.Any)
const disabled = !Reactive.CanSet(settable) || Reactive.IsWriteLocked(settable)
Reactive.Emit('events', { kind: 'value', event: event, settable: settable, disabled: disabled })
})
// Checked - on `event`, set `settable` to the checkbox/radio's boolean `checked` state
// (domEvent.target.checked), not its string value.
Reactive.Function('Checked', () => {
const event = Reactive.Argument(Text)
const settable = Reactive.Argument(Reactive.Any)
const disabled = !Reactive.CanSet(settable) || Reactive.IsWriteLocked(settable)
Reactive.Emit('events', { kind: 'checked', event: event, settable: settable, disabled: disabled })
})
})
// An element. Its body runs in a NESTED Dom scope, which gathers this element's own
// content / attributes / events; Tag then emits ONE element node into the enclosing
// scope. locked propagates: an element is locked if any of its attributes or children is.
Reactive.Function('Tag', () => {
const tagName = Reactive.Argument(Text)
const inner = Reactive.Slot()
const key = Reactive.StableId()
const child = Dom(inner)
const locked = anyLocked(child.attributes) || anyLocked(child.properties) || anyLocked(child.content)
Reactive.Emit('content', { kind: 'element', key: key, tag: tagName, attributes: child.attributes, properties: child.properties, events: child.events, content: child.content, locked: locked })
})
// Loader wraps a subtree in a div. It opens a child scope, and if anything in that
// subtree is locked it appends a spinner overlay. Its own node is NEVER locked: the
// loading state is caught here and does not propagate upward past the loader.
Reactive.Function('Loader', () => {
const inner = Reactive.Slot()
const key = Reactive.StableId()
const spinnerKey = Reactive.StableId()
const spinnerTextKey = Reactive.StableId()
const child = Dom(inner)
const loading = anyLocked(child.content)
const spinner = {
kind: 'element', key: spinnerKey, tag: 'div',
attributes: [{ name: 'class', value: 'dom-spinner' }], events: [],
content: [{ kind: 'text', key: spinnerTextKey, text: 'Loading...', locked: false }],
locked: false,
}
const content = loading ? child.content.concat([spinner]) : child.content
const cssClass = loading ? 'dom-loader loading' : 'dom-loader'
Reactive.Emit('content', {
kind: 'element', key: key, tag: 'div',
attributes: [{ name: 'class', value: cssClass }],
events: [], content: content, locked: false,
})
console.log('[Dom.Loader] loading =', loading)
})
body()
const content = Reactive.Gather('content')
const attributes = Reactive.Gather('attributes')
const properties = Reactive.Gather('properties')
const events = Reactive.Gather('events')
return { content: content, attributes: attributes, properties: properties, events: events }
})
// ---- the reconciler driver ------------------------------------------------
Reactive.Module('Dom', () => {
const RV = Reactive._internal.ReactiveValue
const RG = Reactive._internal.ReactiveGettable
const RS = Reactive._internal.ReactiveSettable
Reactive.Driver('content', (params, addUpdates) => {
const root = params.root
const byKey = new Map() // stable key -> record { el, tag/kind, listeners, attrNames }
let renderCount = 0
function wireEvents(el, rec, events) {
if (rec.listeners != null) {
for (const ev of Object.keys(rec.listeners)) {
el.removeEventListener(ev, rec.listeners[ev])
}
}
rec.listeners = {}
let anyDisabled = false
for (const e of events) {
const descriptor = e
if (descriptor.disabled === true) {
// A write-locked settable: leave the control disabled and do not wire it.
anyDisabled = true
continue
}
// The descriptor carries the settable binding; derive its id here (only reached for a
// non-disabled event, so the binding is settable and Reference is safe).
const settableId = Reactive.Reference(descriptor.settable)
const handler = (domEvent) => {
const value = descriptor.kind === 'value' ? domEvent.target.value
: descriptor.kind === 'checked' ? domEvent.target.checked
: descriptor.value
console.log('[Dom] event', descriptor.event, '-> Set settable', settableId, '=', JSON.stringify(value))
const updates = {}
updates[settableId] = new RV(new RG(undefined, value), new RS(settableId, undefined))
addUpdates(updates)
}
el.addEventListener(descriptor.event, handler)
rec.listeners[descriptor.event] = handler
}
// A DOM property (not an attribute), so it does not collide with the node's declared
// attribute set. Real <button>/<input> honour `disabled`.
el.disabled = anyDisabled
}
function reconcileNode(node) {
if (node.kind === 'text') {
let rec = byKey.get(node.key)
if (rec == null || rec.kind !== 'text') {
rec = { kind: 'text', el: document.createTextNode(String(node.text)) }
byKey.set(node.key, rec)
} else if (rec.el.textContent !== String(node.text)) {
rec.el.textContent = String(node.text)
}
return rec.el
}
// element
let rec = byKey.get(node.key)
if (rec == null || rec.kind !== 'element' || rec.tag !== node.tag) {
rec = { kind: 'element', tag: node.tag, el: document.createElement(node.tag), listeners: {}, attrNames: [] }
byKey.set(node.key, rec)
}
const el = rec.el
const newNames = []
for (const attribute of node.attributes) {
newNames.push(attribute.name)
const asString = String(attribute.value)
if (el.getAttribute(attribute.name) !== asString) {
el.setAttribute(attribute.name, asString)
}
}
for (const oldName of rec.attrNames) {
if (newNames.indexOf(oldName) === -1) {
el.removeAttribute(oldName)
}
}
rec.attrNames = newNames
// Live properties (el.value / el.checked / ...). Compared as strings so a numeric
// binding (12.3) does not perpetually differ from the string the DOM holds ("12.3")
// and clobber the caret on every keystroke; a genuine change (a slider driving the
// readout, a programmatic update) still re-sets the property.
const properties = node.properties == null ? [] : node.properties
for (const property of properties) {
if (String(el[property.name]) !== String(property.value)) {
el[property.name] = property.value
}
}
wireEvents(el, rec, node.events)
reconcileChildren(el, node.content)
return el
}
function reconcileChildren(parentEl, nodes) {
let index = 0
for (const node of nodes) {
const el = reconcileNode(node)
const current = parentEl.childNodes[index]
if (current !== el) {
parentEl.insertBefore(el, current == null ? null : current)
}
index++
}
while (parentEl.childNodes.length > index) {
parentEl.removeChild(parentEl.lastChild)
}
}
return (nodes) => {
renderCount++
console.log('[Dom] render #' + renderCount + ':', nodes.length, 'top-level node(s)')
reconcileChildren(root, nodes)
}
})
})
})()