From f45179f9124fb002e910afb618911c79a4a1549f Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Sun, 31 Mar 2024 10:56:36 +0100 Subject: =?UTF-8?q?=F0=9F=94=80=20Refactor=20vdom=20and=20add=20support=20?= =?UTF-8?q?for=20keyed=20vnodes.=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :truck: Keep around old vdom implementation for reference. * :sparkles: Add a keyed vdom node. * :alembic: Experiment with a different approach for handling component children. * :construction: Here be scary works in progress. * :sparkles: Implement keyed node diffing. * :recycle: Don't use deprecated 'isOk' checks. * :recycle: Remove separate Keyed node and add 'key' field to Element node. * :sparkles: Add support for server components into new vdom. * :bug: Fix broken build script. * :recycle: Don't emit data-lustre-key attributes for server component patches. * :package: Generate server component runtime. * :bug: Fixed a bug where server component keys were ambiguous when double digit. * :package: Generate server component runtime. * :recycle: Refactor 'keyed' element to force all children of a node to be keyed. * :memo: Consistently format '**Note**:'. * :bug: Fixed bug with falsey className/style/innerHTML attributes. * :bug: Fixed a bug not handling undefined 'prev' nodes correctly. * :package: Generate server component runtime. --- src/vdom.ffi.mjs | 696 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 398 insertions(+), 298 deletions(-) (limited to 'src/vdom.ffi.mjs') diff --git a/src/vdom.ffi.mjs b/src/vdom.ffi.mjs index b9dc706..e77db89 100644 --- a/src/vdom.ffi.mjs +++ b/src/vdom.ffi.mjs @@ -1,111 +1,162 @@ -import { Empty } from "./gleam.mjs"; - -export function morph(prev, curr, dispatch, parent) { - if (curr?.subtree) { - return morph(prev, curr.subtree(), dispatch, parent); - } - - // The current node is an `Element` and the previous DOM node is also a DOM - // element. - if (curr?.tag && prev?.nodeType === 1) { - const nodeName = curr.tag.toUpperCase(); - const ns = curr.namespace || "http://www.w3.org/1999/xhtml"; - - // If the current node and the existing DOM node have the same tag and - // namespace, we can morph them together: keeping the DOM node intact and just - // updating its attributes and children. - if (prev.nodeName === nodeName && prev.namespaceURI == ns) { - return morphElement(prev, curr, dispatch, parent); - } - // Otherwise, we need to replace the DOM node with a new one. The `createElement` - // function will handle replacing the existing DOM node for us. - else { - return createElement(prev, curr, dispatch, parent); +// 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) { + prev.textContent = next.content; + out ??= prev; + } else { + const created = document.createTextNode(next.content); + parent.replaceChild(created, prev); + out ??= created; + } } - } - - // The current node is an `Element` but the previous DOM node either did not - // exist or it is not a DOM element (eg it might be a text or comment node). - if (curr?.tag) { - return createElement(prev, curr, dispatch, parent); - } + // 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); + } - // The current node is a `Text`. - if (typeof curr?.content === "string") { - return prev?.nodeType === 3 - ? morphText(prev, curr) - : createText(prev, curr); + out ??= created; + } } - // If someone was naughty and tried to pass in something other than a Lustre - // element (or if there is an actual bug with the runtime!) we'll render a - // comment and ask them to report the issue. - return document.createComment( - [ - "[internal lustre error] I couldn't work out how to render this element. This", - "function should only be called internally by lustre's runtime: if you think", - "this is an error, please open an issue at", - "https://github.com/hayleigh-dot-dev/gleam-lustre/issues/new", - ].join(" "), - ); + 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]; + 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") { - morph(root, created[1], dispatch, root.parentNode); - } else { - const segments = Array.from(key); - const parentKey = segments.slice(0, -1).join(""); - const indexKey = segments.slice(-1)[0]; - const prev = - root.querySelector(`[data-lustre-key="${key}"]`) ?? - root.querySelector(`[data-lustre-key="${parentKey}"]`).childNodes[ - indexKey - ]; - - morph(prev, created[1], dispatch, prev.parentNode); + 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]; - const segments = Array.from(key); - const parentKey = segments.slice(0, -1).join(""); - const indexKey = segments.slice(-1)[0]; - const prev = - root.querySelector(`[data-lustre-key="${key}"]`) ?? - root.querySelector(`[data-lustre-key="${parentKey}"]`).childNodes[ - indexKey - ]; - - prev.remove(); + 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]; - const prev = - key === "0" ? root : root.querySelector(`[data-lustre-key="${key}"]`); + const key = updated[0].split("-"); + const patches = updated[1]; + const prev = getDeepChild(rootParent, key); + const handlersForEl = registeredHandlers.get(prev); - prev.$lustre ??= { __registered_events: new Set() }; + for (const created of patches[0]) { + const name = created[0]; + const value = created[1]; - for (const created of updated[0]) { - morphAttr(prev, created.name, created.value, dispatch); - } + if (name.startsWith("data-lustre-on-")) { + const eventName = name.slice(15); + const callback = dispatch(lustreServerEventHandler); - for (const removed of updated[1]) { - if (prev.$lustre.__registered_events.has(removed)) { - const event = removed.slice(2).toLowerCase(); + if (!handlersForEl.has(eventName)) { + el.addEventListener(eventName, lustreGenericEventHandler); + } - prev.removeEventListener(event, prev.$lustre[`${removed}Handler`]); - prev.$lustre.__registered_events.delete(removed); + handlersForEl.set(eventName, callback); + el.setAttribute(name, value); + } else { + prev.setAttribute(name, value); + prev[name] = value; + } + } - delete prev.$lustre[removed]; - delete prev.$lustre[`${removed}Handler`]; + 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); + prev.removeAttribute(removed[0]); } } } @@ -113,266 +164,287 @@ export function patch(root, diff, dispatch) { return root; } -// ELEMENTS -------------------------------------------------------------------- - -function createElement(prev, curr, dispatch, parent = null) { - const el = curr.namespace - ? document.createElementNS(curr.namespace, curr.tag) - : document.createElement(curr.tag); - - el.$lustre = { - __registered_events: new Set(), - }; +// CREATING ELEMENTS ----------------------------------------------------------- +// +// @todo do we need to special-case ``, `