// The streaming renderer: each snapshot of a program (joule-ui's line language, a tree // every widget of which the registry accepted) reconciled into the page by key. A widget // keeps its instance, and so its state (a room joined, a chat typed), across snapshots // while its key and its widget stay the same; only its props are updated. A widget that // appears is created (and marked, so the page shows it arriving); one that is gone is // destroyed. // // A component is `(ctx, props, node) => ({ el, slot?, update?(props), destroy?() })`: // `el` its element, `slot` where its children go (it holds nothing else). export class Renderer { constructor(container, components, ctx) { this.container = container; this.components = components; this.ctx = ctx; this.inst = new Map(); this.created = 0; this.renders = 0; } render(snap) { this.renders++; const tree = snap && snap.tree; if (!tree) { for (const [k, i] of [...this.inst]) this.destroy(k, i); this.container.replaceChildren(); return; } const nodes = snap.nodes || []; let idx = 0; const seen = new Set(); const walk = (w) => { const node = nodes[idx++] || { key: `#${idx}`, line: 0, statement: "" }; const key = node.key; seen.add(key); let inst = this.inst.get(key); if (inst && inst.name !== w.name) { this.destroy(key, inst); inst = null; } const json = JSON.stringify(w.props || {}); const props = plainProps(w.props); if (!inst) { const make = this.components[w.name] || unknown(w.name); const comp = make(this.ctx, props, node); comp.el.classList.add("wai-w", "wai-new"); comp.el.dataset.key = key; comp.el.dataset.widget = w.name; comp.el.dataset.line = String(node.line); setTimeout(() => comp.el.classList.remove("wai-new"), 1400); inst = { name: w.name, comp, json }; this.inst.set(key, inst); this.created++; } else if (inst.json !== json) { if (inst.comp.update) inst.comp.update(props); inst.json = json; } inst.comp.el.dataset.line = String(node.line); const kids = (w.children || []).map(walk); if (inst.comp.slot) place(inst.comp.slot, kids.map((k) => k.comp.el)); return inst; }; const root = walk(tree); for (const [k, i] of [...this.inst]) if (!seen.has(k)) this.destroy(k, i); if (this.container.children.length !== 1 || this.container.firstElementChild !== root.comp.el) this.container.replaceChildren(root.comp.el); } destroy(key, inst) { try { if (inst.comp.destroy) inst.comp.destroy(); } catch { /* the component's own fault */ } inst.comp.el.remove(); this.inst.delete(key); } // The widgets on the page now, by key. keys() { return [...this.inst.keys()]; } } // A prop value in joule-ui's wire form (`{type, value}`) as a plain value: text, number, // boolean, null, an array, an object, or an action `{ action, payload }`. export function plain(v) { if (!v || typeof v !== "object") return v; switch (v.type) { case "null": return null; case "list": return (v.value || []).map(plain); case "object": return Object.fromEntries(Object.entries(v.value || {}).map(([k, x]) => [k, plain(x)])); case "action": return { action: v.value.id, payload: v.value.payload == null ? null : plain(v.value.payload) }; default: return v.value; } } export function plainProps(props) { return Object.fromEntries(Object.entries(props || {}).map(([k, v]) => [k, plain(v)])); } // Put `els` in `slot` in order, moving only what is out of place (an iframe moved in the // document reloads, so nothing in place is touched). export function place(slot, els) { for (const c of [...slot.children]) if (!els.includes(c)) c.remove(); for (let i = 0; i < els.length; i++) { const at = slot.children[i]; if (at !== els[i]) slot.insertBefore(els[i], at || null); } } function unknown(name) { return () => { const el = document.createElement("div"); el.className = "wai-unknown"; el.textContent = `(${name}: no component for this widget on this page)`; return { el }; }; }