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)
      }
    })
  })
})()