// This is pretty hot code so it's important that we pay good consideration to
// writing performant code even if sometimes that means writing less clean code.
// I'm not exactly a perf witch though, but here are some resources I've collected
// along the way:
//
// - https://romgrk.com/posts/optimizing-javascript
// - https://www.zhenghao.io/posts/object-vs-map
//
// Morph turns a Lustre VDOM node into real DOM nodes. Instead of doing a VDOM
// diff that produces a patch, we morph the VDOM into the real DOM directly.
export function morph(prev, next, dispatch, isComponent = false) {
// This function eventually returns the morphed root node. Because the morphing
// process might involve _removing_ the root in some cases, we can't simply return
// `prev` and hope for the best.
//
// We've also unfolded the recursive implementation into a stack-based iterative
// one so we cant just rely on good ol' recursion to return the root node. Instead
// we track it here and make sure to only set it once.
let out;
// A stack of nodes to still left to morph. This will shrink and grow over the
// course of the function. *Either* `prev` or `next` can be missing, but never
// both. The `parent` is *always* present.
let stack = [{ prev, next, parent: prev.parentNode }];
while (stack.length) {
let { prev, next, parent } = stack.pop();
// If we have the `subtree` property then we're looking at a `Map` vnode that
// is lazily evaluated. We'll force it here and then proceed with the morphing.
if (next.subtree !== undefined) next = next.subtree();
// Text nodes:
if (next.content !== undefined) {
if (!prev) {
const created = document.createTextNode(next.content);
parent.appendChild(created);
out ??= created;
} else if (prev.nodeType === Node.TEXT_NODE) {
if (prev.textContent !== next.content) prev.textContent = next.content;
out ??= prev;
} else {
const created = document.createTextNode(next.content);
parent.replaceChild(created, prev);
out ??= created;
}
}
// Element nodes:
else if (next.tag !== undefined) {
const created = createElementNode({
prev,
next,
dispatch,
stack,
isComponent,
});
if (!prev) {
parent.appendChild(created);
}
// We can morph the new node into the previous one if they are compatible.
// In those cases we wouldn't want to waste time doing a `replaceChild` so
// we're checking explicitly if the new node is different from the previous
// one.
else if (prev !== created) {
parent.replaceChild(created, prev);
}
out ??= created;
}
// If this happens, then the top level Element is a Fragment `prev` should be
// the first element of the given fragment. Functionally, a fragment as the
// first child means that document -> body will be the parent of the first level
// of children
else if (next.elements !== undefined) {
iterateElement(next, (fragmentElement) => {
stack.unshift({ prev, next: fragmentElement, parent });
prev = prev?.nextSibling;
});
}
}
return out;
}
export function patch(root, diff, dispatch) {
const rootParent = root.parentNode;
// A diff is a tuple of three arrays: created, removed, updated. Each of these
// arrays contains tuples of slightly differing shape. You'll have to go peek
// at `src/lustre/internals/patch.gleam` to work out the exact shape.
// A `created` diff is a tuple of `[key, element]` where the `key` is a path
// to the element in the VDOM tree and the `element` is the VDOM node itself.
// Nodes don't have any optimised encoding so they can be passed to `morph`
// without any processing.
//
// We get a created diff if the element is brand new *or* if it changed the tag
// of the element.
for (const created of diff[0]) {
const key = created[0].split("-");
const next = created[1];
const prev = getDeepChild(rootParent, key);
let result;
// If there was a previous node then we can morph the new node into it.
if (prev !== null && prev !== rootParent) {
result = morph(prev, next, dispatch);
}
// Otherwise, we create a temporary node to hold the new node's place in the
// tree. This can happen because we might get a patch that tells us some node
// was created at a path that doesn't exist yet.
else {
const parent = getDeepChild(rootParent, key.slice(0, -1));
const temp = document.createTextNode("");
parent.appendChild(temp);
result = morph(temp, next, dispatch);
}
// Patching the root node means we might end up replacing it entirely so we
// need to reassign the root node if the key is "0".
if (key === "0") {
root = result;
}
}
// A `removed` diff is just a one-element tuple (for consistency) of the key of
// the removed element.
for (const removed of diff[1]) {
const key = removed[0].split("-");
const deletedNode = getDeepChild(rootParent, key);
deletedNode.remove();
}
// An `updated` diff is all about *attributes*. It's a tuple of `[key, patches]`
// where patches is another list of diffs.
for (const updated of diff[2]) {
const key = updated[0].split("-");
const patches = updated[1];
const prev = getDeepChild(rootParent, key);
const handlersForEl = registeredHandlers.get(prev);
for (const created of patches[0]) {
const name = created[0];
const value = created[1];
if (name.startsWith("data-lustre-on-")) {
const eventName = name.slice(15);
const callback = dispatch(lustreServerEventHandler);
if (!handlersForEl.has(eventName)) {
el.addEventListener(eventName, lustreGenericEventHandler);
}
handlersForEl.set(eventName, callback);
el.setAttribute(name, value);
} else {
prev.setAttribute(name, value);
prev[name] = value;
}
}
for (const removed of patches[1]) {
if (removed[0].startsWith("data-lustre-on-")) {
const eventName = removed[0].slice(15);
prev.removeEventListener(eventName, lustreGenericEventHandler);
handlersForEl.delete(eventName);
} else {
prev.removeAttribute(removed[0]);
}
}
}
return root;
}
// CREATING ELEMENTS -----------------------------------------------------------
//
// @todo do we need to special-case ``, `