aboutsummaryrefslogtreecommitdiff
path: root/lib/src/lustre.ffi.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'lib/src/lustre.ffi.mjs')
-rw-r--r--lib/src/lustre.ffi.mjs206
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);
+};