diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2024-03-31 10:56:36 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-31 10:56:36 +0100 |
commit | f45179f9124fb002e910afb618911c79a4a1549f (patch) | |
tree | 64aa1190458124a5556481c4d4b0bcb8db7c1c6b /priv/static/lustre-server-component.mjs | |
parent | b6aea6702d762986a69f5660df78459ef81a2e9b (diff) | |
download | lustre-f45179f9124fb002e910afb618911c79a4a1549f.tar.gz lustre-f45179f9124fb002e910afb618911c79a4a1549f.zip |
🔀 Refactor vdom and add support for keyed vnodes. (#83)
* :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.
Diffstat (limited to 'priv/static/lustre-server-component.mjs')
-rw-r--r-- | priv/static/lustre-server-component.mjs | 618 |
1 files changed, 255 insertions, 363 deletions
diff --git a/priv/static/lustre-server-component.mjs b/priv/static/lustre-server-component.mjs index aeb7d63..f217b44 100644 --- a/priv/static/lustre-server-component.mjs +++ b/priv/static/lustre-server-component.mjs @@ -5,391 +5,257 @@ var init = 2; var event = 4; var attrs = 5; -// build/dev/javascript/prelude.mjs -var CustomType = class { - withFields(fields) { - let properties = Object.keys(this).map( - (label) => label in fields ? fields[label] : this[label] - ); - return new this.constructor(...properties); - } -}; -var List = class { - static fromArray(array, tail) { - let t = tail || new Empty(); - return array.reduceRight((xs, x) => new NonEmpty(x, xs), t); - } - [Symbol.iterator]() { - return new ListIterator(this); - } - toArray() { - return [...this]; - } - atLeastLength(desired) { - for (let _ of this) { - if (desired <= 0) - return true; - desired--; - } - return desired <= 0; - } - hasLength(desired) { - for (let _ of this) { - if (desired <= 0) - return false; - desired--; - } - return desired === 0; - } - countLength() { - let length2 = 0; - for (let _ of this) - length2++; - return length2; - } -}; -var ListIterator = class { - #current; - constructor(current) { - this.#current = current; - } - next() { - if (this.#current instanceof Empty) { - return { done: true }; - } else { - let { head, tail } = this.#current; - this.#current = tail; - return { value: head, done: false }; - } - } -}; -var Empty = class extends List { -}; -var NonEmpty = class extends List { - constructor(head, tail) { - super(); - this.head = head; - this.tail = tail; - } -}; -var Result = class _Result extends CustomType { - static isResult(data) { - return data instanceof _Result; - } -}; -var Ok = class extends Result { - constructor(value) { - super(); - this[0] = value; - } - isOk() { - return true; - } -}; -var Error = class extends Result { - constructor(detail) { - super(); - this[0] = detail; - } - isOk() { - return false; - } -}; - -// build/dev/javascript/gleam_stdlib/dict.mjs -var tempDataView = new DataView(new ArrayBuffer(8)); -var SHIFT = 5; -var BUCKET_SIZE = Math.pow(2, SHIFT); -var MASK = BUCKET_SIZE - 1; -var MAX_INDEX_NODE = BUCKET_SIZE / 2; -var MIN_ARRAY_NODE = BUCKET_SIZE / 4; - -// build/dev/javascript/gleam_stdlib/gleam/result.mjs -function map2(result, fun) { - if (result.isOk()) { - let x = result[0]; - return new Ok(fun(x)); - } else { - let e = result[0]; - return new Error(e); - } -} - // build/dev/javascript/lustre/vdom.ffi.mjs -function morph(prev, curr, dispatch, parent) { - if (curr?.tag && prev?.nodeType === 1) { - const nodeName = curr.tag.toUpperCase(); - const ns = curr.namespace || "http://www.w3.org/1999/xhtml"; - if (prev.nodeName === nodeName && prev.namespaceURI == ns) { - return morphElement(prev, curr, dispatch, parent); - } else { - return createElement(prev, curr, dispatch, parent); +function morph(prev, next, dispatch, isComponent = false) { + let out; + let stack = [{ prev, next, parent: prev.parentNode }]; + while (stack.length) { + let { prev: prev2, next: next2, parent } = stack.pop(); + if (next2.subtree !== void 0) + next2 = next2.subtree(); + if (next2.content !== void 0) { + if (!prev2) { + const created = document.createTextNode(next2.content); + parent.appendChild(created); + out ??= created; + } else if (prev2.nodeType === Node.TEXT_NODE) { + prev2.textContent = next2.content; + out ??= prev2; + } else { + const created = document.createTextNode(next2.content); + parent.replaceChild(created, prev2); + out ??= created; + } + } else if (next2.tag !== void 0) { + const created = createElementNode({ + prev: prev2, + next: next2, + dispatch, + stack, + isComponent + }); + if (!prev2) { + parent.appendChild(created); + } else if (prev2 !== created) { + parent.replaceChild(created, prev2); + } + out ??= created; } } - if (curr?.tag) { - return createElement(prev, curr, dispatch, parent); - } - if (typeof curr?.content === "string") { - return prev?.nodeType === 3 ? morphText(prev, curr) : createText(prev, curr); - } - 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; } function patch(root, diff2, dispatch) { + const rootParent = root.parentNode; for (const created of diff2[0]) { - const key = created[0]; - if (key === "0") { - morph(root, created[1], dispatch, root.parentNode); + const key = created[0].split("-"); + const next = created[1]; + const prev = getDeepChild(rootParent, key); + let result; + if (prev !== null && prev !== rootParent) { + result = morph(prev, next, dispatch); } 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); + const parent = getDeepChild(rootParent, key.slice(0, -1)); + const temp = document.createTextNode(""); + parent.appendChild(temp); + result = morph(temp, next, dispatch); + } + if (key === "0") { + root = result; } } for (const removed of diff2[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(); } for (const updated of diff2[2]) { - const key = updated[0]; - const prev = key === "0" ? root : root.querySelector(`[data-lustre-key="${key}"]`); - prev.$lustre ??= { __registered_events: /* @__PURE__ */ 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 event2 = removed.slice(2).toLowerCase(); - prev.removeEventListener(event2, prev.$lustre[`${removed}Handler`]); - prev.$lustre.__registered_events.delete(removed); - delete prev.$lustre[removed]; - delete prev.$lustre[`${removed}Handler`]; + 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.removeAttribute(removed); + prev.setAttribute(name, value); + prev[name] = value; } } - } - return root; -} -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: /* @__PURE__ */ 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; + 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 { - parentWithSlot = parentWithSlot.parentNode; + prev.removeAttribute(removed[0]); } } - 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; + return root; } -function morphElement(prev, curr, dispatch, parent) { - const prevAttrs = prev.attributes; - const currAttrs = /* @__PURE__ */ new Map(); - prev.$lustre ??= { __registered_events: /* @__PURE__ */ new Set() }; - 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]); - } - } - for (const { name } of prevAttrs) { - if (!currAttrs.has(name)) { - prev.removeAttribute(name); +function createElementNode({ prev, next, dispatch, stack }) { + const namespace = next.namespace || "http://www.w3.org/1999/xhtml"; + const canMorph = prev && prev.nodeType === Node.ELEMENT_NODE && prev.localName === next.tag && prev.namespaceURI === (next.namespace || "http://www.w3.org/1999/xhtml"); + const el2 = canMorph ? prev : namespace ? document.createElementNS(namespace, next.tag) : document.createElement(next.tag); + let handlersForEl; + if (!registeredHandlers.has(el2)) { + const emptyHandlers = /* @__PURE__ */ new Map(); + registeredHandlers.set(el2, emptyHandlers); + handlersForEl = emptyHandlers; + } else { + handlersForEl = registeredHandlers.get(el2); + } + const prevHandlers = canMorph ? new Set(handlersForEl.keys()) : null; + const prevAttributes = canMorph ? new Set(Array.from(prev.attributes, (a) => a.name)) : null; + let className = null; + let style = null; + let innerHTML = null; + for (const attr of next.attrs) { + const name = attr[0]; + const value = attr[1]; + const isProperty = attr[2]; + if (isProperty) { + el2[name] = value; + } else if (name.startsWith("on")) { + const eventName = name.slice(2); + const callback = dispatch(value); + if (!handlersForEl.has(eventName)) { + el2.addEventListener(eventName, lustreGenericEventHandler); + } + handlersForEl.set(eventName, callback); + if (canMorph) + prevHandlers.delete(eventName); + } else if (name.startsWith("data-lustre-on-")) { + const eventName = name.slice(15); + const callback = dispatch(lustreServerEventHandler); + if (!handlersForEl.has(eventName)) { + el2.addEventListener(eventName, lustreGenericEventHandler); + } + handlersForEl.set(eventName, callback); + el2.setAttribute(name, value); + } else if (name === "class") { + className = className === null ? value : className + " " + value; + } else if (name === "style") { + style = style === null ? value : style + value; + } else if (name === "dangerous-unescaped-html") { + innerHTML = value; } else { - const value = currAttrs.get(name); - morphAttr(prev, name, value, dispatch); - currAttrs.delete(name); + el2.setAttribute(name, value); + el2[name] = value; + if (canMorph) + prevAttributes.delete(name); } } - for (const name of prev.$lustre.__registered_events) { - if (!currAttrs.has(name)) { - const event2 = name.slice(2).toLowerCase(); - prev.removeEventListener(event2, prev.$lustre[`${name}Handler`]); - prev.$lustre.__registered_events.delete(name); - delete prev.$lustre[name]; - delete prev.$lustre[`${name}Handler`]; - } + if (className !== null) { + el2.setAttribute("class", className); + if (canMorph) + prevAttributes.delete("class"); } - for (const [name, value] of currAttrs) { - morphAttr(prev, name, value, dispatch); + if (style !== null) { + el2.setAttribute("style", style); + if (canMorph) + prevAttributes.delete("style"); } - 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; + if (canMorph) { + for (const attr of prevAttributes) { + el2.removeAttribute(attr); } - 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)); + for (const eventName of prevHandlers) { + el2.removeEventListener(eventName, lustreGenericEventHandler); } } - return prev; -} -function morphAttr(el, name, value, dispatch) { - switch (typeof value) { - case (name.startsWith("data-lustre-on-") && "string"): { - if (!value) { - el.removeAttribute(name); - el.removeEventListener(event2, el.$lustre[`${name}Handler`]); - break; + if (next.key !== void 0 && next.key !== "") { + el2.setAttribute("data-lustre-key", next.key); + } else if (innerHTML !== null) { + el2.innerHTML = innerHTML; + return el2; + } + let prevChild = prev?.firstChild; + let seenKeys = null; + let keyedChildren = null; + let incomingKeyedChildren = null; + let firstChild = next.children[Symbol.iterator]().next().value; + if (firstChild !== void 0 && // Explicit checks are more verbose but truthy checks force a bunch of comparisons + // we don't care about: it's never gonna be a number etc. + firstChild.key !== void 0 && firstChild.key !== "") { + seenKeys = /* @__PURE__ */ new Set(); + keyedChildren = getKeyedChildren(prev); + incomingKeyedChildren = getKeyedChildren(next); + } + for (const child of next.children) { + if (child.key !== void 0 && seenKeys !== null) { + while (prevChild && !incomingKeyedChildren.has(prevChild.getAttribute("data-lustre-key"))) { + const nextChild = prevChild.nextSibling; + el2.removeChild(prevChild); + prevChild = nextChild; } - if (el.hasAttribute(name)) - break; - const event2 = name.slice(15).toLowerCase(); - const handler = (e) => dispatch(serverEventHandler(e)); - if (el.$lustre[`${name}Handler`]) { - el.removeEventListener(event2, el.$lustre[`${name}Handler`]); + if (keyedChildren.size === 0) { + stack.unshift({ prev: prevChild, next: child, parent: el2 }); + prevChild = prevChild?.nextSibling; + continue; } - el.addEventListener(event2, handler); - el.$lustre[name] = value; - el.$lustre[`${name}Handler`] = handler; - el.$lustre.__registered_events.add(name); - el.setAttribute(name, value); - break; - } - case "string": - if (name === "value") - el.value = value; - if (value === "") { - el.removeAttribute(name); - } else { - el.setAttribute(name, value); + if (seenKeys.has(child.key)) { + console.warn(`Duplicate key found in Lustre vnode: ${child.key}`); + stack.unshift({ prev: null, next: child, parent: el2 }); + continue; } - break; - case (name.startsWith("on") && "function"): { - if (el.$lustre[name] === value) - break; - const event2 = name.slice(2).toLowerCase(); - const handler = (e) => map2(value(e), dispatch); - if (el.$lustre[`${name}Handler`]) { - el.removeEventListener(event2, el.$lustre[`${name}Handler`]); + seenKeys.add(child.key); + const keyedChild = keyedChildren.get(child.key); + if (!keyedChild && !prevChild) { + stack.unshift({ prev: null, next: child, parent: el2 }); + continue; } - el.addEventListener(event2, handler); - el.$lustre[name] = value; - el.$lustre[`${name}Handler`] = handler; - el.$lustre.__registered_events.add(name); - break; + if (!keyedChild && prevChild !== null) { + const placeholder = document.createTextNode(""); + el2.insertBefore(placeholder, prevChild); + stack.unshift({ prev: placeholder, next: child, parent: el2 }); + continue; + } + if (!keyedChild || keyedChild === prevChild) { + stack.unshift({ prev: prevChild, next: child, parent: el2 }); + prevChild = prevChild?.nextSibling; + continue; + } + el2.insertBefore(keyedChild, prevChild); + stack.unshift({ prev: keyedChild, next: child, parent: el2 }); + } else { + stack.unshift({ prev: prevChild, next: child, parent: el2 }); + prevChild = prevChild?.nextSibling; } - default: - el[name] = value; } -} -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; + while (prevChild) { + const next2 = prevChild.nextSibling; + el2.removeChild(prevChild); + prevChild = next2; } - if (prevValue !== currValue) - prev.nodeValue = currValue; - return prev; + return el2; } -function serverEventHandler(event2) { - const el = event2.target; - const tag = el.getAttribute(`data-lustre-on-${event2.type}`); - const data = JSON.parse(el.getAttribute("data-lustre-data") || "{}"); - const include = JSON.parse(el.getAttribute("data-lustre-include") || "[]"); +var registeredHandlers = /* @__PURE__ */ new WeakMap(); +function lustreGenericEventHandler(event2) { + if (!registeredHandlers.has(event2.target)) { + event2.target.removeEventListener(event2.type, lustreGenericEventHandler); + return; + } + const handlersForEventTarget = registeredHandlers.get(event2.target); + if (!handlersForEventTarget.has(event2.type)) { + event2.target.removeEventListener(event2.type, lustreGenericEventHandler); + return; + } + handlersForEventTarget.get(event2.type)(event2); +} +function lustreServerEventHandler(event2) { + const el2 = event2.target; + const tag = el2.getAttribute(`data-lustre-on-${event2.type}`); + const data = JSON.parse(el2.getAttribute("data-lustre-data") || "{}"); + const include = JSON.parse(el2.getAttribute("data-lustre-include") || "[]"); switch (event2.type) { case "input": case "change": @@ -398,21 +264,45 @@ function serverEventHandler(event2) { } return { tag, - data: include.reduce((data2, property) => { - const path = property.split("."); - for (let i = 0, o = data2, e = event2; 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]]; + data: include.reduce( + (data2, property) => { + const path = property.split("."); + for (let i = 0, o = data2, e = event2; 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 data2; - }, data) + return data2; + }, + { data } + ) }; } +function getKeyedChildren(el2) { + const keyedChildren = /* @__PURE__ */ new Map(); + if (el2) { + for (const child of el2.children) { + const key = child.key || child?.getAttribute("data-lustre-key"); + if (key) + keyedChildren.set(key, child); + } + } + return keyedChildren; +} +function getDeepChild(el2, path) { + let n; + let rest; + let child = el2; + while ([n, ...rest] = path, n !== void 0) { + child = child.childNodes.item(n); + path = rest; + } + return child; +} // src/server-component.mjs var LustreServerComponent = class extends HTMLElement { @@ -520,12 +410,14 @@ var LustreServerComponent = class extends HTMLElement { } } morph(vdom) { - this.#root = morph(this.#root, vdom, (msg) => { + this.#root = morph(this.#root, vdom, (handler) => (event2) => { + const msg = handler(event2); this.#socket?.send(JSON.stringify([event, msg.tag, msg.data])); }); } diff([diff2]) { - this.#root = patch(this.#root, diff2, (msg) => { + this.#root = patch(this.#root, diff2, (handler) => (event2) => { + const msg = handler(event2); this.#socket?.send(JSON.stringify([event, msg.tag, msg.data])); }); } |