aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/lustre.ffi.mjs85
-rw-r--r--src/runtime.ffi.mjs166
2 files changed, 189 insertions, 62 deletions
diff --git a/src/lustre.ffi.mjs b/src/lustre.ffi.mjs
index fa022d1..3a6556e 100644
--- a/src/lustre.ffi.mjs
+++ b/src/lustre.ffi.mjs
@@ -1,10 +1,6 @@
-import { innerHTML, createTree } from "./runtime.ffi.mjs";
-import { Ok, Error, List } from "./gleam.mjs";
-import {
- Some,
- None,
- map as option_map,
-} from "../gleam_stdlib/gleam/option.mjs";
+import { morphdom } from "./runtime.ffi.mjs";
+import { Ok, Error } from "./gleam.mjs";
+import { map } from "./lustre/element.mjs";
// RUNTIME ---------------------------------------------------------------------
@@ -18,33 +14,30 @@ export class App {
#willUpdate = false;
#didUpdate = false;
- // These are the three functions that the user provides to the runtime.
- #__init;
- #__update;
- #__render;
-
constructor(init, update, render) {
- this.#__init = init;
- this.#__update = update;
- this.#__render = render;
+ this.#init = init;
+ this.#update = update;
+ this.#view = render;
}
start(selector = "body") {
if (this.#root) return this;
try {
- this.#root = document.querySelector(selector);
+ const el = document.querySelector(selector);
+ const [next, cmds] = this.#init();
+
+ this.#root = el;
+ this.#state = next;
+ this.#commands = cmds[0].toArray();
+ this.#didUpdate = true;
+
+ window.requestAnimationFrame(() => this.#tick());
+
+ return new Ok((msg) => this.dispatch(msg));
} catch (_) {
return new Error(undefined);
}
-
- const [next, cmds] = this.#__init();
- this.#state = next;
- this.#commands = cmds[0].toArray();
- this.#didUpdate = true;
-
- window.requestAnimationFrame(() => this.#tick());
- return new Ok((msg) => this.dispatch(msg));
}
dispatch(msg) {
@@ -55,22 +48,23 @@ export class App {
}
#render() {
- const node = this.#__render(this.#state);
- const tree = createTree(map(node, (msg) => this.dispatch(msg)));
+ const node = this.#view(this.#state);
+ const vdom = map(node, (msg) => this.dispatch(msg));
- innerHTML(this.#root, tree);
+ morphdom(this.#root.firstChild, vdom);
}
#tick() {
this.#flush();
this.#didUpdate && this.#render();
+ this.#didUpdate = false;
this.#willUpdate = false;
}
#flush(times = 0) {
if (this.#queue.length) {
while (this.#queue.length) {
- const [next, cmds] = this.#__update(this.#state, this.#queue.shift());
+ const [next, cmds] = this.#update(this.#state, this.#queue.shift());
this.#state = next;
this.#commands.concat(cmds[0].toArray());
@@ -92,37 +86,4 @@ export class App {
}
export const setup = (init, update, render) => new App(init, update, render);
-export const start = (app, selector = "body") => app.start(selector);
-
-// VDOM ------------------------------------------------------------------------
-
-export const node = (tag, attrs, children) =>
- createTree(tag, Object.fromEntries(attrs.toArray()), children.toArray());
-export const text = (content) => content;
-export const attr = (key, value) => {
- if (value instanceof List) return [key, value.toArray()];
- if (value instanceof Some) return [key, value[0]];
- if (value instanceof None) return [key, undefined];
-
- return [key, value];
-};
-export const on = (event, handler) => [`on${event}`, handler];
-export const map = (node, f) => ({
- ...node,
- attributes: Object.entries(node.attributes).reduce((attrs, [key, value]) => {
- // It's safe to mutate the `attrs` object here because we created it at
- // the start of the reduce: it's not shared with any other code.
-
- // If the attribute is an event handler, wrap it in a function that
- // transforms
- if (key.startsWith("on") && typeof value === "function") {
- attrs[key] = (e) => option_map(value(e), f);
- } else {
- attrs[key] = value;
- }
-
- return attrs;
- }, {}),
- childNodes: node.childNodes.map((child) => map(child, f)),
-});
-export const styles = (list) => Object.fromEntries(list.toArray());
+export const start = (app, selector) => app.start(selector);
diff --git a/src/runtime.ffi.mjs b/src/runtime.ffi.mjs
index 28066fe..c349a70 100644
--- a/src/runtime.ffi.mjs
+++ b/src/runtime.ffi.mjs
@@ -1,3 +1,5 @@
+import { h, t } from "./lustre/element.mjs";
+
// This file is vendored from https://github.com/patrick-steele-idem/morphdom/
// and is licensed under the MIT license. For a copy of the original license
// head on over to:
@@ -780,3 +782,167 @@ function morphdomFactory(morphAttrs) {
export const morphdom = morphdomFactory(morphAttrs);
export default morphdom;
+
+// VDOM HACKS ------------------------------------------------------------------
+
+// Whew this is some Naughty Stuff™. Our `Element` Gleam type is opaque so the
+// class constructors are not exposed and we can't import them. We need to
+// massage these classes into a shape that morphdom knows how to deal with, and
+// because we can't get at the constructors directly we're going to be sneaky and
+// get in by constructing some dummy elements and stealing their prototypes.
+//
+// Morphdom expects the following properties/methods to exist on a valid VDOM
+// node:
+//
+// - firstChild;
+// - nextSibling;
+// - nodeType;
+// - nodeName;
+// - namespaceURI;
+// - nodeValue;
+// - attributes;
+// - value;
+// - selected;
+// - disabled;
+// - hasAttributeNS(namespaceURI, name);
+// - actualize(document);
+//
+// See more here: https://github.com/patrick-steele-idem/morphdom/blob/master/docs/virtual-dom.md
+//
+
+Object.defineProperties(h("").constructor.prototype, {
+ firstChild: {
+ get() {
+ // If this is the first time `firstChild` is being accessed, we need to
+ // create the children array first.
+ if (!this.children) this.children = this[2].toArray();
+
+ const child = this.children[0];
+
+ if (child) {
+ child.parentElement = this;
+ child.index = 0;
+ }
+
+ return child;
+ },
+ },
+
+ nextSibling: {
+ get() {
+ const sibling = this.parentElement?.children[this.index + 1];
+
+ if (sibling) {
+ sibling.parentElement = this.parentElement;
+ sibling.index = this.index + 1;
+ }
+
+ return sibling;
+ },
+ },
+
+ nodeType: {
+ value: ELEMENT_NODE,
+ },
+
+ nodeName: {
+ get() {
+ return this[0].toUpperCase();
+ },
+ },
+
+ namespaceURI: {
+ value: undefined,
+ },
+
+ nodeValue: {
+ value: null,
+ },
+
+ attributes: {
+ get() {
+ if (!this._attributes) {
+ this._attributes = Object.fromEntries(this[1].toArray());
+ }
+
+ return this._attributes;
+ },
+ },
+
+ value: {
+ get() {
+ return this.attributes.value;
+ },
+ },
+
+ selected: {
+ get() {
+ return !!this.attributes.selected;
+ },
+ },
+
+ disabled: {
+ get() {
+ return !!this.attributes.disabled;
+ },
+ },
+
+ hasAttributeNS: {
+ value: function (_, name) {
+ return name in this.attributes;
+ },
+ },
+
+ actualize: {
+ value: function (document) {
+ const el = document.createElement(this[0]);
+
+ for (const key in this.attributes) {
+ el[key] = this.attributes[key];
+ }
+
+ for (let child = this.firstChild; !!child; child = child.nextSibling) {
+ el.appendChild(child.actualize(document));
+ }
+
+ return el;
+ },
+ },
+});
+
+Object.defineProperties(t("").constructor.prototype, {
+ nextSibling: {
+ get() {
+ const sibling = this.parentElement?.children[this.index + 1];
+
+ if (sibling) {
+ sibling.parentElement = this.parentElement;
+ sibling.index = this.index + 1;
+ }
+
+ return sibling;
+ },
+ },
+
+ nodeType: {
+ get() {
+ return TEXT_NODE;
+ },
+ },
+
+ nodeName: {
+ value: "#text",
+ },
+
+ nodeValue: {
+ get() {
+ return this[0];
+ },
+ },
+
+ actualize: {
+ value: function (document) {
+ return document.createTextNode(this[0]);
+ },
+ },
+});