diff options
Diffstat (limited to 'lib/src/lustre.ffi.mjs')
-rw-r--r-- | lib/src/lustre.ffi.mjs | 206 |
1 files changed, 206 insertions, 0 deletions
diff --git a/lib/src/lustre.ffi.mjs b/lib/src/lustre.ffi.mjs new file mode 100644 index 0000000..b99f6e2 --- /dev/null +++ b/lib/src/lustre.ffi.mjs @@ -0,0 +1,206 @@ +import { ElementNotFound, ComponentAlreadyRegistered } from "./lustre.mjs"; +import { from } from "./lustre/effect.mjs"; +import { map } from "./lustre/element.mjs"; +import { morph } from "./runtime.ffi.mjs"; +import { Ok, Error, isEqual } from "./gleam.mjs"; + +// RUNTIME --------------------------------------------------------------------- + +/// +/// +export class App { + #root = null; + #state = null; + #queue = []; + #effects = []; + #didUpdate = false; + + #init = null; + #update = null; + #view = null; + + constructor(init, update, render) { + this.#init = init; + this.#update = update; + this.#view = render; + } + + start(selector = "body") { + if (this.#root) return this; + + try { + const el = + selector instanceof HTMLElement + ? selector + : document.querySelector(selector); + const [next, effects] = this.#init(); + + this.#root = el; + this.#state = next; + this.#effects = effects[0].toArray(); + this.#didUpdate = true; + + window.requestAnimationFrame(() => this.#tick()); + + return new Ok((msg) => this.dispatch(msg)); + } catch (_) { + return new Error(new ElementNotFound()); + } + } + + dispatch(msg) { + this.#queue.push(msg); + this.#tick(); + } + + emit(name, event = null) { + this.#root.dispatchEvent( + new CustomEvent(name, { + bubbles: true, + detail: event, + composed: true, + }) + ); + } + + destroy() { + this.#root.remove(); + this.#state = null; + this.#queue = []; + this.#effects = []; + this.#didUpdate = false; + this.#update = () => {}; + this.#view = () => {}; + } + + #render() { + const node = this.#view(this.#state); + const vdom = map(node, (msg) => this.dispatch(msg)); + + morph(this.#root, vdom); + } + + #tick() { + this.#flush(); + this.#didUpdate && this.#render(); + this.#didUpdate = false; + } + + #flush(times = 0) { + if (this.#queue.length) { + while (this.#queue.length) { + const [next, effects] = this.#update(this.#state, this.#queue.shift()); + + this.#state = next; + this.#effects = this.#effects.concat(effects[0].toArray()); + } + this.#didUpdate = true; + } + + // Each update can produce effects which must now be executed. + while (this.#effects[0]) + this.#effects.shift()( + (msg) => this.dispatch(msg), + (name, data) => this.emit(name, data) + ); + + // Synchronous effects will immediately queue a message to be processed. If + // it is reasonable, we can process those updates too before proceeding to + // the next render. + if (this.#queue.length) { + times >= 5 ? console.warn(tooManyUpdates) : this.#flush(++times); + } + } +} + +export const setup = (init, update, render) => new App(init, update, render); +export const start = (app, selector) => app.start(selector); + +export const emit = (name, data) => + // Normal `Effect`s constructed in Gleam from `effect.from` don't get told + // about the second argument, but it's there 👀. + from((_, emit) => { + emit(name, data); + }); + +// HTML EVENTS ----------------------------------------------------------------- + +export const prevent_default = (e) => e.preventDefault?.(); +export const stop_propagation = (e) => e.stopPropagation?.(); + +// CUSTOM ELEMENTS ------------------------------------------------------------- + +export const setup_component = ( + name, + init, + update, + render, + on_attribute_change +) => { + if (customElements.get(name)) { + return new Error(new ComponentAlreadyRegistered()); + } + + customElements.define( + name, + class extends HTMLElement { + static get observedAttributes() { + return on_attribute_change.entries().map(([name, _]) => name); + } + + #container = document.createElement("div"); + #app = null; + #dispatch = null; + + constructor() { + super(); + + this.#app = new App(init, update, render); + const dispatch = this.#app.start(this.#container); + this.#dispatch = dispatch[0]; + + on_attribute_change.forEach((decoder, name) => { + Object.defineProperty(this, name, { + get: () => { + return this[`_${name}`] || this.getAttribute(name); + }, + + set: (value) => { + const prev = this[name]; + const decoded = decoder(value); + + // We need this equality check to prevent constantly dispatching + // messages when the value is an object or array: it might not have + // changed but its reference might have and we don't want to trigger + // useless updates. + if (decoded.isOk() && !isEqual(prev, decoded[0])) { + this.#dispatch(decoded[0]); + } + + if (typeof value === "string") { + this.setAttribute(name, value); + } else { + this[`_${name}`] = value; + } + }, + }); + }); + } + + connectedCallback() { + this.appendChild(this.#container); + } + + attributeChangedCallback(name, prev, next) { + if (prev !== next) { + this[name] = next; + } + } + + disconnectedCallback() { + this.#app.destroy(); + } + } + ); + return new Ok(null); +}; |