diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2024-01-23 00:09:45 +0000 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-01-23 00:09:45 +0000 |
commit | 24f6962aa457d32319756f6217aafde7b0a9c752 (patch) | |
tree | 42119d9b073f56eabe9dda4ae2065ef4b2086e6a /src/vdom.ffi.mjs | |
parent | 45e671ac32de95ae1a0a9f9e98da8645d01af3cf (diff) | |
download | lustre-24f6962aa457d32319756f6217aafde7b0a9c752.tar.gz lustre-24f6962aa457d32319756f6217aafde7b0a9c752.zip |
✨ Add universal components that can run on the server (#39)
* :heavy_plus_sign: Add gleam_erlang gleam_otp and gleam_json dependencies.
* :sparkles: Add json encoders for elememnts and attributes.
* :sparkles: Add the ability to perform an effect with a custom dispatch function.
* :construction: Experiment with a server-side component runtime.
* :construction: Expose special server click events.
* :construction: Experiment with a server-side component runtime.
* :construction: Experiment with a server-side component runtime.
* :construction: Experiment with a server-side component runtime.
* :construction: Create a basic server component client bundle.
* :construction: Create a basic server component demo.
* :bug: Fixed a bug where the runtime stopped performing patches.
* :refactor: Roll back introduction of shadow dom.
* :recycle: Refactor to Custom Element-based approach to encapsulating server components.
* :truck: Move some things around.
* :sparkles: Add a minified version of the server component runtime.
* :wrench: Add lustre/server/* to internal modules.
* :recycle: on_attribute_change and on_client_event handlers are now functions not dicts.
* :recycle: Refactor server component event handling to no longer need explicit tags.
* :fire: Remove unnecessary attempt to stringify events.
* :memo: Start documeint lustre/server functions.
* :construction: Experiment with a js implementation of the server component backend runtime.
* :recycle: Experiment with an API that makes heavier use of conditional complilation.
* :recycle: Big refactor to unify server components, client components, and client apps.
* :bug: Fixed some bugs with client runtimes.
* :recycle: Update examples to new lustre api/
* :truck: Move server demo into examples/ folder/
* :wrench: Add lustre/runtime to internal modules.
* :construction: Experiment with a diffing implementation.
* :wrench: Hide internal modules from docs.
* :heavy_plus_sign: Update deps to latest versions.
* :recycle: Move diffing and vdom code into separate internal modules.
* :sparkles: Bring server components to feature parity with client components.
* :recycle: Update server component demo.
* :bug: Fix bug where attribute changes weren't properly broadcast.
* :fire: Remove unused 'Patch' type.
* :recycle: Stub out empty js implementations so we can build for js.
* :memo: Docs for the docs gods.
* :recycle: Rename lustre.server_component to lustre.component.
Diffstat (limited to 'src/vdom.ffi.mjs')
-rw-r--r-- | src/vdom.ffi.mjs | 407 |
1 files changed, 407 insertions, 0 deletions
diff --git a/src/vdom.ffi.mjs b/src/vdom.ffi.mjs new file mode 100644 index 0000000..ec5a226 --- /dev/null +++ b/src/vdom.ffi.mjs @@ -0,0 +1,407 @@ +import { Empty } from "./gleam.mjs"; +import { map as result_map } from "../gleam_stdlib/gleam/result.mjs"; + +export function morph(prev, curr, 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); + } + } + + // 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); + } + + // The current node is a `Text`. + if (typeof curr?.content === "string") { + return prev?.nodeType === 3 + ? morphText(prev, curr) + : createText(prev, curr); + } + + // 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(" ") + ); +} + +export function patch(root, diff, dispatch) { + for (const created of diff[0]) { + const key = created[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); + } + } + + 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(); + } + + for (const updated of diff[2]) { + const key = updated[0]; + const prev = + key === "0" ? root : root.querySelector(`[data-lustre-key="${key}"]`); + + prev.$lustre ??= { __registered_events: new Set() }; + + for (const created of updated[0]) { + morphAttr(prev, created.name, created.value, dispatch); + } + + for (const removed of updated[1]) { + if (prev.$lustre.__registered_events.has(removed)) { + const event = removed.slice(2).toLowerCase(); + + prev.removeEventListener(event, prev.$lustre[`${removed}Handler`]); + prev.$lustre.__registered_events.delete(removed); + + delete prev.$lustre[removed]; + delete prev.$lustre[`${removed}Handler`]; + } else { + prev.removeAttribute(removed); + } + } + } + + 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(), + }; + + let dangerousUnescapedHtml = ""; + + for (const attr of curr.attrs) { + if (attr[0] === "class") { + morphAttr(el, attr[0], `${el.className} ${attr[1]}`); + } else if (attr[0] === "style") { + morphAttr(el, attr[0], `${el.style.cssText} ${attr[1]}`); + } else if (attr[0] === "dangerous-unescaped-html") { + dangerousUnescapedHtml += attr[1]; + } else if (attr[0] !== "") { + morphAttr(el, attr[0], attr[1], dispatch); + } + } + + if (customElements.get(curr.tag)) { + el._slot = curr.children; + } else if (curr.tag === "slot") { + let children = new Empty(); + let parentWithSlot = parent; + + while (parentWithSlot) { + if (parentWithSlot._slot) { + children = parentWithSlot._slot; + break; + } else { + parentWithSlot = parentWithSlot.parentNode; + } + } + + for (const child of children) { + el.appendChild(morph(null, child, dispatch, el)); + } + } else if (dangerousUnescapedHtml) { + el.innerHTML = dangerousUnescapedHtml; + } else { + for (const child of curr.children) { + el.appendChild(morph(null, child, dispatch, el)); + } + } + + if (prev) prev.replaceWith(el); + + return el; +} + +function morphElement(prev, curr, dispatch, parent) { + const prevAttrs = prev.attributes; + const currAttrs = new Map(); + + // This can happen if we're morphing an existing DOM element that *wasn't* + // initially created by lustre. + prev.$lustre ??= { __registered_events: new Set() }; + + // We're going to convert the Gleam List of attributes into a JavaScript Map + // so its easier to lookup specific attributes. + for (const currAttr of curr.attrs) { + if (currAttr[0] === "class" && currAttrs.has("class")) { + currAttrs.set(currAttr[0], `${currAttrs.get("class")} ${currAttr[1]}`); + } else if (currAttr[0] === "style" && currAttrs.has("style")) { + currAttrs.set(currAttr[0], `${currAttrs.get("style")} ${currAttr[1]}`); + } else if ( + currAttr[0] === "dangerous-unescaped-html" && + currAttrs.has("dangerous-unescaped-html") + ) { + currAttrs.set( + currAttr[0], + `${currAttrs.get("dangerous-unescaped-html")} ${currAttr[1]}` + ); + } else if (currAttr[0] !== "") { + currAttrs.set(currAttr[0], currAttr[1]); + } + } + + // TODO: Event listeners aren't currently removed when they are removed from + // the attributes list. This is a bug! + for (const { name, value: prevValue } of prevAttrs) { + if (!currAttrs.has(name)) { + prev.removeAttribute(name); + } else { + const value = currAttrs.get(name); + + if (value !== prevValue) { + morphAttr(prev, name, value, dispatch); + currAttrs.delete(name); + } + } + } + + for (const name of prev.$lustre.__registered_events) { + if (!currAttrs.has(name)) { + const event = name.slice(2).toLowerCase(); + + prev.removeEventListener(event, prev.$lustre[`${name}Handler`]); + prev.$lustre.__registered_events.delete(name); + + delete prev.$lustre[name]; + delete prev.$lustre[`${name}Handler`]; + } + } + + for (const [name, value] of currAttrs) { + morphAttr(prev, name, value, dispatch); + } + + if (customElements.get(curr.tag)) { + prev._slot = curr.children; + } else if (curr.tag === "slot") { + let prevChild = prev.firstChild; + let currChild = new Empty(); + let parentWithSlot = parent; + + while (parentWithSlot) { + if (parentWithSlot._slot) { + currChild = parentWithSlot._slot; + break; + } else { + parentWithSlot = parentWithSlot.parentNode; + } + } + + while (prevChild) { + if (Array.isArray(currChild) && currChild.length) { + morph(prevChild, currChild.shift(), dispatch, prev); + } else if (currChild.head) { + morph(prevChild, currChild.head, dispatch, prev); + currChild = currChild.tail; + } + + prevChild = prevChild.nextSibling; + } + + for (const child of currChild) { + prev.appendChild(morph(null, child, dispatch, prev)); + } + } else if (currAttrs.has("dangerous-unescaped-html")) { + prev.innerHTML = currAttrs.get("dangerous-unescaped-html"); + } else { + let prevChild = prev.firstChild; + let currChild = curr.children; + + while (prevChild) { + if (Array.isArray(currChild) && currChild.length) { + const next = prevChild.nextSibling; + morph(prevChild, currChild.shift(), dispatch, prev); + prevChild = next; + } else if (currChild.head) { + const next = prevChild.nextSibling; + morph(prevChild, currChild.head, dispatch, prev); + currChild = currChild.tail; + prevChild = next; + } else { + const next = prevChild.nextSibling; + prevChild.remove(); + prevChild = next; + } + } + + for (const child of currChild) { + prev.appendChild(morph(null, child, dispatch, prev)); + } + } + + return prev; +} + +// ATTRIBUTES ------------------------------------------------------------------ + +function morphAttr(el, name, value, dispatch) { + switch (typeof value) { + case name.startsWith("data-lustre-on-") && "string": { + if (!value) { + el.removeAttribute(name); + el.removeEventListener(event, el.$lustre[`${name}Handler`]); + + break; + } + if (el.hasAttribute(name)) break; + + const event = name.slice(15).toLowerCase(); + const handler = (e) => dispatch(serverEventHandler(e)); + + if (el.$lustre[`${name}Handler`]) { + el.removeEventListener(event, el.$lustre[`${name}Handler`]); + } + + el.addEventListener(event, handler); + + el.$lustre[name] = value; + el.$lustre[`${name}Handler`] = handler; + el.$lustre.__registered_events.add(name); + el.setAttribute(name, value); + + break; + } + + case "string": + if (el.getAttribute(name) !== value) el.setAttribute(name, value); + if (value === "") el.removeAttribute(name); + if (name === "value" && el.value !== value) el.value = value; + break; + + // Event listeners need to be handled slightly differently because we need + // to be able to support custom events. We + case name.startsWith("on") && "function": { + if (el.$lustre[name] === value) break; + + const event = name.slice(2).toLowerCase(); + const handler = (e) => result_map(value(e), dispatch); + + if (el.$lustre[`${name}Handler`]) { + el.removeEventListener(event, el.$lustre[`${name}Handler`]); + } + + el.addEventListener(event, handler); + + el.$lustre[name] = value; + el.$lustre[`${name}Handler`] = handler; + el.$lustre.__registered_events.add(name); + + break; + } + + default: + el[name] = value; + } +} + +// TEXT ------------------------------------------------------------------------ + +function createText(prev, curr) { + const el = document.createTextNode(curr.content); + + if (prev) prev.replaceWith(el); + return el; +} + +function morphText(prev, curr) { + const prevValue = prev.nodeValue; + const currValue = curr.content; + + if (!currValue) { + prev?.remove(); + return null; + } + + if (prevValue !== currValue) prev.nodeValue = currValue; + + return prev; +} + +// UTILS ----------------------------------------------------------------------- + +function serverEventHandler(event) { + const el = event.target; + const tag = el.getAttribute(`data-lustre-on-${event.type}`); + const data = JSON.parse(el.getAttribute("data-lustre-data") || "{}"); + const include = JSON.parse(el.getAttribute("data-lustre-include") || "[]"); + + switch (event.type) { + case "input": + case "change": + include.push("target.value"); + break; + } + + return { + tag, + data: include.reduce((data, property) => { + const path = property.split("."); + + for (let i = 0, o = data, e = event; i < path.length; i++) { + if (i === path.length - 1) { + o[path[i]] = e[path[i]]; + } else { + o[path[i]] ??= {}; + e = e[path[i]]; + o = o[path[i]]; + } + } + + return data; + }, data), + }; +} |