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. --- pages/reference/examples.md | 4 - priv/static/lustre-server-component.min.mjs | 2 +- priv/static/lustre-server-component.mjs | 618 ++++++++++-------------- priv/templates/component-entry.mjs | 4 - priv/templates/entry-with-main.mjs | 3 - priv/templates/entry-with-start.mjs | 4 - priv/templates/entry.css | 3 - priv/templates/index-with-lustre-ui.html | 19 - priv/templates/index.html | 15 - src/client-component.ffi.mjs | 5 +- src/client-runtime.ffi.mjs | 21 +- src/lustre.gleam | 10 +- src/lustre/effect.gleam | 6 +- src/lustre/element.gleam | 75 ++- src/lustre/internals/patch.gleam | 15 +- src/lustre/internals/runtime.gleam | 8 +- src/lustre/internals/vdom.gleam | 23 +- src/lustre/server_component.gleam | 14 +- src/runtime.ffi.mjs | 344 -------------- src/server-component.mjs | 6 +- src/vdom.ffi.mjs | 696 ++++++++++++++++------------ test/build.gleam | 4 +- 22 files changed, 794 insertions(+), 1105 deletions(-) delete mode 100644 priv/templates/component-entry.mjs delete mode 100644 priv/templates/entry-with-main.mjs delete mode 100644 priv/templates/entry-with-start.mjs delete mode 100644 priv/templates/entry.css delete mode 100644 priv/templates/index-with-lustre-ui.html delete mode 100644 priv/templates/index.html delete mode 100644 src/runtime.ffi.mjs diff --git a/pages/reference/examples.md b/pages/reference/examples.md index d6535a8..a6a0d37 100644 --- a/pages/reference/examples.md +++ b/pages/reference/examples.md @@ -43,7 +43,3 @@ the previous ones. Feel free to jump in to any example that interests you, thoug If you're having trouble with Lustre or not sure what the right way to do something is, the best place to get help is the [Gleam Discord server](https://discord.gg/Fm8Pwmy). You could also open an issue on the [Lustre GitHub repository](https://github.com/lustre-labs/lustre/issues). - -While our docs are still a work in progress, the official [Elm guide](https://guide.elm-lang.org) -is also a great resource for learning about the Model-View-Update architecture -and the kinds of patterns that Lustre is built around. diff --git a/priv/static/lustre-server-component.min.mjs b/priv/static/lustre-server-component.min.mjs index 979b991..2f790b8 100644 --- a/priv/static/lustre-server-component.min.mjs +++ b/priv/static/lustre-server-component.min.mjs @@ -1 +1 @@ -var c=class{withFields(e){let n=Object.keys(this).map(u=>u in e?e[u]:this[u]);return new this.constructor(...n)}},w=class{static fromArray(e,n){let u=n||new d;return e.reduceRight((i,s)=>new y(s,i),u)}[Symbol.iterator](){return new k(this)}toArray(){return[...this]}atLeastLength(e){for(let n of this){if(e<=0)return!0;e--}return e<=0}hasLength(e){for(let n of this){if(e<=0)return!1;e--}return e===0}countLength(){let e=0;for(let n of this)e++;return e}};var k=class{#t;constructor(e){this.#t=e}next(){if(this.#t instanceof d)return{done:!0};{let{head:e,tail:n}=this.#t;return this.#t=n,{value:e,done:!1}}}},d=class extends w{},y=class extends w{constructor(e,n){super(),this.head=e,this.tail=n}};var m=class t extends c{static isResult(e){return e instanceof t}},p=class extends m{constructor(e){super(),this[0]=e}isOk(){return!0}},a=class extends m{constructor(e){super(),this[0]=e}isOk(){return!1}};var Mt=new DataView(new ArrayBuffer(8));var X=5,E=Math.pow(2,X),qt=E-1,Bt=E/2,Dt=E/4;function A(t,e){if(t.isOk()){let n=t[0];return new p(e(n))}else{let n=t[0];return new a(n)}}function f(t,e,n,u){if(e?.tag&&t?.nodeType===1){let i=e.tag.toUpperCase(),s=e.namespace||"http://www.w3.org/1999/xhtml";return t.nodeName===i&&t.namespaceURI==s?Q(t,e,n,u):B(t,e,n,u)}return e?.tag?B(t,e,n,u):typeof e?.content=="string"?t?.nodeType===3?te(t,e):ee(t,e):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(" "))}function D(t,e,n){for(let u of e[0]){let i=u[0];if(i==="0")f(t,u[1],n,t.parentNode);else{let s=Array.from(i),r=s.slice(0,-1).join(""),l=s.slice(-1)[0],o=t.querySelector(`[data-lustre-key="${i}"]`)??t.querySelector(`[data-lustre-key="${r}"]`).childNodes[l];f(o,u[1],n,o.parentNode)}}for(let u of e[1]){let i=u[0],s=Array.from(i),r=s.slice(0,-1).join(""),l=s.slice(-1)[0];(t.querySelector(`[data-lustre-key="${i}"]`)??t.querySelector(`[data-lustre-key="${r}"]`).childNodes[l]).remove()}for(let u of e[2]){let i=u[0],s=i==="0"?t:t.querySelector(`[data-lustre-key="${i}"]`);s.$lustre??={__registered_events:new Set};for(let r of u[0])x(s,r.name,r.value,n);for(let r of u[1])if(s.$lustre.__registered_events.has(r)){let l=r.slice(2).toLowerCase();s.removeEventListener(l,s.$lustre[`${r}Handler`]),s.$lustre.__registered_events.delete(r),delete s.$lustre[r],delete s.$lustre[`${r}Handler`]}else s.removeAttribute(r)}return t}function B(t,e,n,u=null){let i=e.namespace?document.createElementNS(e.namespace,e.tag):document.createElement(e.tag);i.$lustre={__registered_events:new Set};let s="";for(let r of e.attrs)r[0]==="class"?x(i,r[0],`${i.className} ${r[1]}`):r[0]==="style"?x(i,r[0],`${i.style.cssText} ${r[1]}`):r[0]==="dangerous-unescaped-html"?s+=r[1]:r[0]!==""&&x(i,r[0],r[1],n);if(customElements.get(e.tag))i._slot=e.children;else if(e.tag==="slot"){let r=new d,l=u;for(;l;)if(l._slot){r=l._slot;break}else l=l.parentNode;for(let o of r)i.appendChild(f(null,o,n,i))}else if(s)i.innerHTML=s;else for(let r of e.children)i.appendChild(f(null,r,n,i));return t&&t.replaceWith(i),i}function Q(t,e,n,u){let i=t.attributes,s=new Map;t.$lustre??={__registered_events:new Set};for(let r of e.attrs)r[0]==="class"&&s.has("class")?s.set(r[0],`${s.get("class")} ${r[1]}`):r[0]==="style"&&s.has("style")?s.set(r[0],`${s.get("style")} ${r[1]}`):r[0]==="dangerous-unescaped-html"&&s.has("dangerous-unescaped-html")?s.set(r[0],`${s.get("dangerous-unescaped-html")} ${r[1]}`):r[0]!==""&&s.set(r[0],r[1]);for(let{name:r}of i)if(!s.has(r))t.removeAttribute(r);else{let l=s.get(r);x(t,r,l,n),s.delete(r)}for(let r of t.$lustre.__registered_events)if(!s.has(r)){let l=r.slice(2).toLowerCase();t.removeEventListener(l,t.$lustre[`${r}Handler`]),t.$lustre.__registered_events.delete(r),delete t.$lustre[r],delete t.$lustre[`${r}Handler`]}for(let[r,l]of s)x(t,r,l,n);if(customElements.get(e.tag))t._slot=e.children;else if(e.tag==="slot"){let r=t.firstChild,l=new d,o=u;for(;o;)if(o._slot){l=o._slot;break}else o=o.parentNode;for(;r;)Array.isArray(l)&&l.length?f(r,l.shift(),n,t):l.head&&(f(r,l.head,n,t),l=l.tail),r=r.nextSibling;for(let h of l)t.appendChild(f(null,h,n,t))}else if(s.has("dangerous-unescaped-html"))t.innerHTML=s.get("dangerous-unescaped-html");else{let r=t.firstChild,l=e.children;for(;r;)if(Array.isArray(l)&&l.length){let o=r.nextSibling;f(r,l.shift(),n,t),r=o}else if(l.head){let o=r.nextSibling;f(r,l.head,n,t),l=l.tail,r=o}else{let o=r.nextSibling;r.remove(),r=o}for(let o of l)t.appendChild(f(null,o,n,t))}return t}function x(t,e,n,u){switch(typeof n){case(e.startsWith("data-lustre-on-")&&"string"):{if(!n){t.removeAttribute(e),t.removeEventListener(i,t.$lustre[`${e}Handler`]);break}if(t.hasAttribute(e))break;let i=e.slice(15).toLowerCase(),s=r=>u(re(r));t.$lustre[`${e}Handler`]&&t.removeEventListener(i,t.$lustre[`${e}Handler`]),t.addEventListener(i,s),t.$lustre[e]=n,t.$lustre[`${e}Handler`]=s,t.$lustre.__registered_events.add(e),t.setAttribute(e,n);break}case"string":e==="value"&&(t.value=n),n===""?t.removeAttribute(e):t.setAttribute(e,n);break;case(e.startsWith("on")&&"function"):{if(t.$lustre[e]===n)break;let i=e.slice(2).toLowerCase(),s=r=>A(n(r),u);t.$lustre[`${e}Handler`]&&t.removeEventListener(i,t.$lustre[`${e}Handler`]),t.addEventListener(i,s),t.$lustre[e]=n,t.$lustre[`${e}Handler`]=s,t.$lustre.__registered_events.add(e);break}default:t[e]=n}}function ee(t,e){let n=document.createTextNode(e.content);return t&&t.replaceWith(n),n}function te(t,e){let n=t.nodeValue,u=e.content;return u?(n!==u&&(t.nodeValue=u),t):(t?.remove(),null)}function re(t){let e=t.target,n=e.getAttribute(`data-lustre-on-${t.type}`),u=JSON.parse(e.getAttribute("data-lustre-data")||"{}"),i=JSON.parse(e.getAttribute("data-lustre-include")||"[]");switch(t.type){case"input":case"change":i.push("target.value");break}return{tag:n,data:i.reduce((s,r)=>{let l=r.split(".");for(let o=0,h=s,b=t;o{let n=[];for(let u of e)if(u.type==="attributes"){let{attributeName:i,oldValue:s}=u,r=this.getAttribute(i);if(s!==r)try{n.push([i,JSON.parse(r)])}catch{n.push([i,r])}}n.length&&this.#e?.send(JSON.stringify([5,n]))})}connectedCallback(){this.#r=document.createElement("div"),this.appendChild(this.#r)}attributeChangedCallback(e,n,u){switch(e){case"route":if(!u)this.#e?.close(),this.#e=null;else if(n!==u){let i=this.getAttribute("id"),s=u+(i?`?id=${i}`:"");this.#e?.close(),this.#e=new WebSocket(`ws://${window.location.host}${s}`),this.#e.addEventListener("message",r=>this.messageReceivedCallback(r))}}}messageReceivedCallback({data:e}){let[n,...u]=JSON.parse(e);switch(n){case 0:return this.diff(u);case 1:return this.emit(u);case 2:return this.init(u)}}init([e,n]){let u=[];for(let i of e)i in this?u.push([i,this[i]]):this.hasAttribute(i)&&u.push([i,this.getAttribute(i)]),Object.defineProperty(this,i,{get(){return this[`_${i}`]??this.getAttribute(i)},set(s){let r=this[i];typeof s=="string"?this.setAttribute(i,s):this[`_${i}`]=s,r!==s&&this.#e?.send(JSON.stringify([5,[[i,s]]]))}});this.#t.observe(this,{attributeFilter:e,attributeOldValue:!0,attributes:!0,characterData:!1,characterDataOldValue:!1,childList:!1,subtree:!1}),this.morph(n),u.length&&this.#e?.send(JSON.stringify([5,u]))}morph(e){this.#r=f(this.#r,e,n=>{this.#e?.send(JSON.stringify([4,n.tag,n.data]))})}diff([e]){this.#r=D(this.#r,e,n=>{this.#e?.send(JSON.stringify([4,n.tag,n.data]))})}emit([e,n]){this.dispatchEvent(new CustomEvent(e,{detail:n}))}disconnectedCallback(){this.#e?.close()}};window.customElements.define("lustre-server-component",C);export{C as LustreServerComponent}; +function k(i,n,r,s=!1){let t,l=[{prev:i,next:n,parent:i.parentNode}];for(;l.length;){let{prev:e,next:o,parent:u}=l.pop();if(o.subtree!==void 0&&(o=o.subtree()),o.content!==void 0)if(e)if(e.nodeType===Node.TEXT_NODE)e.textContent=o.content,t??=e;else{let a=document.createTextNode(o.content);u.replaceChild(a,e),t??=a}else{let a=document.createTextNode(o.content);u.appendChild(a),t??=a}else if(o.tag!==void 0){let a=$({prev:e,next:o,dispatch:r,stack:l,isComponent:s});e?e!==a&&u.replaceChild(a,e):u.appendChild(a),t??=a}}return t}function T(i,n,r){let s=i.parentNode;for(let t of n[0]){let l=t[0].split("-"),e=t[1],o=N(s,l),u;if(o!==null&&o!==s)u=k(o,e,r);else{let a=N(s,l.slice(0,-1)),f=document.createTextNode("");a.appendChild(f),u=k(f,e,r)}l==="0"&&(i=u)}for(let t of n[1]){let l=t[0].split("-");N(s,l).remove()}for(let t of n[2]){let l=t[0].split("-"),e=t[1],o=N(s,l),u=v.get(o);for(let a of e[0]){let f=a[0],m=a[1];if(f.startsWith("data-lustre-on-")){let b=f.slice(15),d=r(J);u.has(b)||el.addEventListener(b,y),u.set(b,d),el.setAttribute(f,m)}else o.setAttribute(f,m),o[f]=m}for(let a of e[1])if(a[0].startsWith("data-lustre-on-")){let f=a[0].slice(15);o.removeEventListener(f,y),u.delete(f)}else o.removeAttribute(a[0])}return i}function $({prev:i,next:n,dispatch:r,stack:s}){let t=n.namespace||"http://www.w3.org/1999/xhtml",l=i&&i.nodeType===Node.ELEMENT_NODE&&i.localName===n.tag&&i.namespaceURI===(n.namespace||"http://www.w3.org/1999/xhtml"),e=l?i:t?document.createElementNS(t,n.tag):document.createElement(n.tag),o;if(v.has(e))o=v.get(e);else{let c=new Map;v.set(e,c),o=c}let u=l?new Set(o.keys()):null,a=l?new Set(Array.from(i.attributes,c=>c.name)):null,f=null,m=null,b=null;for(let c of n.attrs){let h=c[0],p=c[1];if(c[2])e[h]=p;else if(h.startsWith("on")){let g=h.slice(2),A=r(p);o.has(g)||e.addEventListener(g,y),o.set(g,A),l&&u.delete(g)}else if(h.startsWith("data-lustre-on-")){let g=h.slice(15),A=r(J);o.has(g)||e.addEventListener(g,y),o.set(g,A),e.setAttribute(h,p)}else h==="class"?f=f===null?p:f+" "+p:h==="style"?m=m===null?p:m+p:h==="dangerous-unescaped-html"?b=p:(e.setAttribute(h,p),e[h]=p,l&&a.delete(h))}if(f!==null&&(e.setAttribute("class",f),l&&a.delete("class")),m!==null&&(e.setAttribute("style",m),l&&a.delete("style")),l){for(let c of a)e.removeAttribute(c);for(let c of u)e.removeEventListener(c,y)}if(n.key!==void 0&&n.key!=="")e.setAttribute("data-lustre-key",n.key);else if(b!==null)return e.innerHTML=b,e;let d=i?.firstChild,C=null,w=null,O=null,E=n.children[Symbol.iterator]().next().value;E!==void 0&&E.key!==void 0&&E.key!==""&&(C=new Set,w=L(i),O=L(n));for(let c of n.children)if(c.key!==void 0&&C!==null){for(;d&&!O.has(d.getAttribute("data-lustre-key"));){let p=d.nextSibling;e.removeChild(d),d=p}if(w.size===0){s.unshift({prev:d,next:c,parent:e}),d=d?.nextSibling;continue}if(C.has(c.key)){console.warn(`Duplicate key found in Lustre vnode: ${c.key}`),s.unshift({prev:null,next:c,parent:e});continue}C.add(c.key);let h=w.get(c.key);if(!h&&!d){s.unshift({prev:null,next:c,parent:e});continue}if(!h&&d!==null){let p=document.createTextNode("");e.insertBefore(p,d),s.unshift({prev:p,next:c,parent:e});continue}if(!h||h===d){s.unshift({prev:d,next:c,parent:e}),d=d?.nextSibling;continue}e.insertBefore(h,d),s.unshift({prev:h,next:c,parent:e})}else s.unshift({prev:d,next:c,parent:e}),d=d?.nextSibling;for(;d;){let c=d.nextSibling;e.removeChild(d),d=c}return e}var v=new WeakMap;function y(i){if(!v.has(i.target)){i.target.removeEventListener(i.type,y);return}let n=v.get(i.target);if(!n.has(i.type)){i.target.removeEventListener(i.type,y);return}n.get(i.type)(i)}function J(i){let n=i.target,r=n.getAttribute(`data-lustre-on-${i.type}`),s=JSON.parse(n.getAttribute("data-lustre-data")||"{}"),t=JSON.parse(n.getAttribute("data-lustre-include")||"[]");switch(i.type){case"input":case"change":t.push("target.value");break}return{tag:r,data:t.reduce((l,e)=>{let o=e.split(".");for(let u=0,a=l,f=i;u{let r=[];for(let s of n)if(s.type==="attributes"){let{attributeName:t,oldValue:l}=s,e=this.getAttribute(t);if(l!==e)try{r.push([t,JSON.parse(e)])}catch{r.push([t,e])}}r.length&&this.#e?.send(JSON.stringify([5,r]))})}connectedCallback(){this.#t=document.createElement("div"),this.appendChild(this.#t)}attributeChangedCallback(n,r,s){switch(n){case"route":if(!s)this.#e?.close(),this.#e=null;else if(r!==s){let t=this.getAttribute("id"),l=s+(t?`?id=${t}`:"");this.#e?.close(),this.#e=new WebSocket(`ws://${window.location.host}${l}`),this.#e.addEventListener("message",e=>this.messageReceivedCallback(e))}}}messageReceivedCallback({data:n}){let[r,...s]=JSON.parse(n);switch(r){case 0:return this.diff(s);case 1:return this.emit(s);case 2:return this.init(s)}}init([n,r]){let s=[];for(let t of n)t in this?s.push([t,this[t]]):this.hasAttribute(t)&&s.push([t,this.getAttribute(t)]),Object.defineProperty(this,t,{get(){return this[`_${t}`]??this.getAttribute(t)},set(l){let e=this[t];typeof l=="string"?this.setAttribute(t,l):this[`_${t}`]=l,e!==l&&this.#e?.send(JSON.stringify([5,[[t,l]]]))}});this.#n.observe(this,{attributeFilter:n,attributeOldValue:!0,attributes:!0,characterData:!1,characterDataOldValue:!1,childList:!1,subtree:!1}),this.morph(r),s.length&&this.#e?.send(JSON.stringify([5,s]))}morph(n){this.#t=k(this.#t,n,r=>s=>{let t=r(s);this.#e?.send(JSON.stringify([4,t.tag,t.data]))})}diff([n]){this.#t=T(this.#t,n,r=>s=>{let t=r(s);this.#e?.send(JSON.stringify([4,t.tag,t.data]))})}emit([n,r]){this.dispatchEvent(new CustomEvent(n,{detail:r}))}disconnectedCallback(){this.#e?.close()}};window.customElements.define("lustre-server-component",S);export{S as LustreServerComponent}; 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])); }); } diff --git a/priv/templates/component-entry.mjs b/priv/templates/component-entry.mjs deleted file mode 100644 index c9e2823..0000000 --- a/priv/templates/component-entry.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { register } from '../dev/javascript/lustre/client-component.ffi.mjs'; -import { name, {component_name} as component } from '../dev/javascript/{app_name}/{module_path}.mjs'; - -register(component(), name); \ No newline at end of file diff --git a/priv/templates/entry-with-main.mjs b/priv/templates/entry-with-main.mjs deleted file mode 100644 index c0c151c..0000000 --- a/priv/templates/entry-with-main.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { main } from "../dev/javascript/{app_name}/{app_name}.mjs"; - -main(); diff --git a/priv/templates/entry-with-start.mjs b/priv/templates/entry-with-start.mjs deleted file mode 100644 index 785cf0f..0000000 --- a/priv/templates/entry-with-start.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import { start } from "../dev/javascript/lustre/lustre.mjs"; -import { main } from "../dev/javascript/{app_name}/{app_name}.mjs"; - -start(main(), "#app", undefined); diff --git a/priv/templates/entry.css b/priv/templates/entry.css deleted file mode 100644 index bd6213e..0000000 --- a/priv/templates/entry.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; \ No newline at end of file diff --git a/priv/templates/index-with-lustre-ui.html b/priv/templates/index-with-lustre-ui.html deleted file mode 100644 index f7a51f0..0000000 --- a/priv/templates/index-with-lustre-ui.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - 🚧 {app_name} - - - - - - -
- - diff --git a/priv/templates/index.html b/priv/templates/index.html deleted file mode 100644 index 42bd2bf..0000000 --- a/priv/templates/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - 🚧 {app_name} - - - - - -
- - diff --git a/src/client-component.ffi.mjs b/src/client-component.ffi.mjs index 2337f92..89e3470 100644 --- a/src/client-component.ffi.mjs +++ b/src/client-component.ffi.mjs @@ -27,6 +27,8 @@ function makeComponent(init, update, view, on_attribute_change) { #root = document.createElement("div"); #application = null; + slotContent = []; + static get observedAttributes() { return on_attribute_change[0]?.entries().map(([name, _]) => name) ?? []; } @@ -43,7 +45,7 @@ function makeComponent(init, update, view, on_attribute_change) { const prev = this[name]; const decoded = decoder(value); - if (decoded.isOk() && !isEqual(prev, value)) { + if (decoded instanceof Ok && !isEqual(prev, value)) { this.#application ? this.#application.send(new Dispatch(decoded[0])) : window.requestAnimationFrame(() => @@ -67,6 +69,7 @@ function makeComponent(init, update, view, on_attribute_change) { update, view, this.#root, + true, ); this.appendChild(this.#root); } diff --git a/src/client-runtime.ffi.mjs b/src/client-runtime.ffi.mjs index ab80fbf..51c2eef 100644 --- a/src/client-runtime.ffi.mjs +++ b/src/client-runtime.ffi.mjs @@ -8,6 +8,7 @@ export class LustreClientApplication { #queue = []; #effects = []; #didUpdate = false; + #isComponent = false; #model = null; #update = null; @@ -25,13 +26,20 @@ export class LustreClientApplication { return new Ok((msg) => app.send(msg)); } - constructor([model, effects], update, view, root = document.body) { + constructor( + [model, effects], + update, + view, + root = document.body, + isComponent = false, + ) { this.#model = model; this.#update = update; this.#view = view; this.#root = root; this.#effects = effects.all.toArray(); this.#didUpdate = true; + this.#isComponent = isComponent; window.requestAnimationFrame(() => this.#tick()); } @@ -69,15 +77,16 @@ export class LustreClientApplication { this.#flush_queue(); const vdom = this.#view(this.#model); - - this.#didUpdate = false; - this.#root = morph(this.#root, vdom, (handler) => (e) => { + const dispatch = (handler) => (e) => { const result = handler(e); - if (result.isOk()) { + if (result instanceof Ok) { this.send(new Dispatch(result[0])); } - }); + }; + + this.#didUpdate = false; + this.#root = morph(this.#root, vdom, dispatch, this.#isComponent); } #flush_queue(iterations = 0) { diff --git a/src/lustre.gleam b/src/lustre.gleam index 31861f3..f5b79f9 100644 --- a/src/lustre.gleam +++ b/src/lustre.gleam @@ -343,7 +343,7 @@ pub fn application( /// other HTML element. This dictionary of decoders allows you to specify how to /// decode those attributes into messages your component's update loop can handle. /// -/// **Note:** Lustre components are conceptually a lot "heavier" than components +/// **Note**: Lustre components are conceptually a lot "heavier" than components /// in frameworks like React. They should be used for more complex UI widgets /// like a combobox with complex keyboard interactions rather than simple things /// like buttons or text inputs. Where possible try to think about how to build @@ -406,7 +406,7 @@ fn do_start( /// A server component will keep running until the program is terminated or the /// [`shutdown`](#shutdown) action is sent to it. /// -/// **Note:** Users running their application on the BEAM should use [`start_actor`](#start_actor) +/// **Note**: Users running their application on the BEAM should use [`start_actor`](#start_actor) /// instead to make use of Gleam's OTP abstractions. /// @external(javascript, "./server-runtime.ffi.mjs", "start") @@ -423,7 +423,7 @@ pub fn start_server_component( /// a [`Subject`](https://hexdocs.pm/gleam_erlang/gleam/erlang/process.html#Subject) /// /// -/// **Note:** This function is only meaningful on the Erlang target. Attempts to +/// **Note**: This function is only meaningful on the Erlang target. Attempts to /// call it on the JavaScript will result in the `NotErlang` error. If you're running /// a Lustre server component on Node or Deno, use [`start_server_component`](#start_server_component) /// instead. @@ -463,12 +463,12 @@ fn do_start_actor( /// with the name `my-component`, you'd use it in HTML by writing `` /// or in Lustre by rendering `element("my-component", [], [])`. /// -/// **Note:** There are [some rules](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#valid_custom_element_names) +/// **Note**: There are [some rules](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#valid_custom_element_names) /// for what names are valid for a Custom Element. The most important one is that /// the name *must* contain a hypen so that it can be distinguished from standard /// HTML elements. /// -/// **Note:** This function is only meaningful when running in the browser and will +/// **Note**: This function is only meaningful when running in the browser and will /// produce a `NotABrowser` error if called anywhere else. For server contexts, /// you can render a Lustre server component using [`start_server_component`](#start_server_component) /// or [`start_actor`](#start_actor) instead. diff --git a/src/lustre/effect.gleam b/src/lustre/effect.gleam index b3c85ba..22f082d 100644 --- a/src/lustre/effect.gleam +++ b/src/lustre/effect.gleam @@ -97,7 +97,7 @@ pub fn none() -> Effect(msg) { /// Batch multiple effects to be performed at the same time. /// -/// **Note:** The runtime makes no guarantees about the order on which effects +/// **Note**: The runtime makes no guarantees about the order on which effects /// are performed! If you need to chain or sequence effects together, you have /// two broad options: /// @@ -117,7 +117,7 @@ pub fn batch(effects: List(Effect(msg))) -> Effect(msg) { /// Transform the result of an effect. This is useful for mapping over effects /// produced by other libraries or modules. /// -/// **Note:** Remember that effects are not _required_ to dispatch any messages. +/// **Note**: Remember that effects are not _required_ to dispatch any messages. /// Your mapping function may never be called! /// pub fn map(effect: Effect(a), f: fn(a) -> b) -> Effect(b) { @@ -131,7 +131,7 @@ pub fn map(effect: Effect(a), f: fn(a) -> b) -> Effect(b) { /// This is primarily used internally by the server component runtime, but it is /// may also useful for testing. /// -/// **Note:** For now, you should **not** consider this function a part of the +/// **Note**: For now, you should **not** consider this function a part of the /// public API. It may be removed in a future minor or patch release. If you have /// a specific use case for this function, we'd love to hear about it! Please /// reach out on the [Gleam Discord](https://discord.gg/Fm8Pwmy) or diff --git a/src/lustre/element.gleam b/src/lustre/element.gleam index 5b8c9d6..c504ebf 100644 --- a/src/lustre/element.gleam +++ b/src/lustre/element.gleam @@ -20,7 +20,7 @@ import lustre/internals/vdom.{Element, Map, Text} /// variable is used to represent the types of messages that can be produced from /// events on the element or its children. /// -/// **Note:** Just because an element _can_ produces messages of a given type, +/// **Note**: Just because an element _can_ produces messages of a given type, /// doesn't mean that it _will_! The `msg` type variable is used to represent the /// potential for messages to be produced, not a guarantee. /// @@ -61,7 +61,7 @@ pub type Element(msg) = /// function is particularly handing when constructing custom elements, either /// from your own Lustre components or from external JavaScript libraries. /// -/// **Note:** Because Lustre is primarily used to create HTML, this function +/// **Note**: Because Lustre is primarily used to create HTML, this function /// spcieal-cases the following tags render as /// [void elements](https://developer.mozilla.org/en-US/docs/Glossary/Void_element): /// @@ -104,9 +104,20 @@ pub fn element( | "param" | "source" | "track" - | "wbr" -> Element("", tag, attrs, [], False, True) + | "wbr" -> + Element( + key: "", + namespace: "", + tag: tag, + attrs: attrs, + children: [], + self_closing: False, + void: True, + ) + _ -> Element( + key: "", namespace: "", tag: tag, attrs: attrs, @@ -117,6 +128,59 @@ pub fn element( } } +/// Keying elements is an optimisation that helps the runtime reuse existing DOM +/// nodes in cases where children are reordered or removed from a list. Maybe you +/// have a list of elements that can be filtered or sorted in some way, or additions +/// to the front are common. In these cases, keying elements can help Lustre avoid +/// unecessary DOM manipulations by pairing the DOM nodes with the elements in the +/// list that share the same key. +/// +/// You can easily take an element from `lustre/element/html` and key its children +/// by making use of Gleam's [function capturing syntax](https://tour.gleam.run/functions/function-captures/): +/// +/// ```gleam +/// import gleam/list +/// import lustre/element +/// import lustre/element/html +/// +/// fn example() { +/// element.keyed(html.ul([], _), { +/// use item <- list.map(todo_list) +/// let child = html.li([], [view_item(item)]) +/// +/// #(item.id, child) +/// }) +/// } +/// ``` +/// +/// **Note**: The key must be unique within the list of children, but it doesn't +/// have to be unique across the whole application. It's fine to use the same key +/// in different lists. +/// +/// +pub fn keyed( + el: fn(List(Element(msg))) -> Element(msg), + children: List(#(String, Element(msg))), +) -> Element(msg) { + el({ + use #(key, child) <- list.map(children) + + case child { + Element(_, namespace, tag, attrs, children, self_closing, void) -> + Element( + key: key, + namespace: namespace, + tag: tag, + attrs: attrs, + children: children, + self_closing: self_closing, + void: void, + ) + _ -> child + } + }) +} + /// A function for constructing elements in a specific XML namespace. This can /// be used to construct SVG or MathML elements, for example. /// @@ -127,6 +191,7 @@ pub fn namespaced( children: List(Element(msg)), ) -> Element(msg) { Element( + key: "", namespace: namespace, tag: tag, attrs: attrs, @@ -150,6 +215,7 @@ pub fn advanced( void: Bool, ) -> Element(msg) { Element( + key: "", namespace: namespace, tag: tag, attrs: attrs, @@ -204,9 +270,10 @@ pub fn map(element: Element(a), f: fn(a) -> b) -> Element(b) { case element { Text(content) -> Text(content) Map(subtree) -> Map(fn() { map(subtree(), f) }) - Element(namespace, tag, attrs, children, self_closing, void) -> + Element(key, namespace, tag, attrs, children, self_closing, void) -> Map(fn() { Element( + key: key, namespace: namespace, tag: tag, attrs: list.map(attrs, attribute.map(_, f)), diff --git a/src/lustre/internals/patch.gleam b/src/lustre/internals/patch.gleam index 86d05bc..ecf9a89 100644 --- a/src/lustre/internals/patch.gleam +++ b/src/lustre/internals/patch.gleam @@ -82,10 +82,10 @@ fn do_elements( // We previously had an element node but now we have a text node. All we // need to do is mark the new one as created and it will replace the old // element during patching. - Element(_, _, _, _, _, _), Text(_) -> + Element(_, _, _, _, _, _, _), Text(_) -> ElementDiff(..diff, created: dict.insert(diff.created, key, new)) - Text(_), Element(_, _, _, _, _, _) as new -> + Text(_), Element(_, _, _, _, _, _, _) as new -> ElementDiff( ..diff, created: dict.insert(diff.created, key, new), @@ -96,7 +96,8 @@ fn do_elements( // for both their namespaces and their tags to be the same. If that is // the case, we can dif their attributes to see what (if anything) has // changed, and then recursively diff their children. - Element(old_ns, old_tag, old_attrs, old_children, _, _), Element( + Element(_, old_ns, old_tag, old_attrs, old_children, _, _), Element( + _, new_ns, new_tag, new_attrs, @@ -128,7 +129,7 @@ fn do_elements( // sizes and zips them together, padding the shorter list with `None`. let children = zip(old_children, new_children) use diff, #(old, new), pos <- list.index_fold(children, diff) - let key = key <> int.to_string(pos) + let key = key <> "-" <> int.to_string(pos) do_elements(diff, old, new, key) } @@ -136,7 +137,7 @@ fn do_elements( // When we have two elements, but their namespaces or their tags differ, // there is nothing to diff. We mark the new element as created and // extract any event handlers. - Element(_, _, _, _, _, _), Element(_, _, _, _, _, _) as new -> + Element(_, _, _, _, _, _, _), Element(_, _, _, _, _, _, _) as new -> ElementDiff( ..diff, created: dict.insert(diff.created, key, new), @@ -349,7 +350,7 @@ fn fold_event_handlers( case element { Text(_) -> handlers Map(subtree) -> fold_event_handlers(handlers, subtree(), key) - Element(_, _, attrs, children, _, _) -> { + Element(_, _, _, attrs, children, _, _) -> { let handlers = list.fold(attrs, handlers, fn(handlers, attr) { case event_handler(attr) { @@ -361,7 +362,7 @@ fn fold_event_handlers( } }) use handlers, child, index <- list.index_fold(children, handlers) - let key = key <> int.to_string(index) + let key = key <> "-" <> int.to_string(index) fold_event_handlers(handlers, child, key) } diff --git a/src/lustre/internals/runtime.gleam b/src/lustre/internals/runtime.gleam index 95a17a8..a2b5365 100644 --- a/src/lustre/internals/runtime.gleam +++ b/src/lustre/internals/runtime.gleam @@ -52,7 +52,7 @@ pub type DebugAction { // ACTOR ----------------------------------------------------------------------- -// @target(erlang) +@target(erlang) /// /// pub fn start( @@ -87,7 +87,7 @@ pub fn start( actor.start_spec(Spec(init, timeout, loop)) } -// @target(erlang) +@target(erlang) fn loop( message: Action(msg, runtime), state: State(model, msg, runtime), @@ -203,7 +203,7 @@ fn loop( // UTILS ----------------------------------------------------------------------- -// @target(erlang) +@target(erlang) fn run_renderers( renderers: Dict(any, fn(Patch(msg)) -> Nil), patch: Patch(msg), @@ -212,7 +212,7 @@ fn run_renderers( renderer(patch) } -// @target(erlang) +@target(erlang) fn run_effects(effects: Effect(msg), self: Subject(Action(msg, runtime))) -> Nil { let dispatch = fn(msg) { actor.send(self, Dispatch(msg)) } let emit = fn(name, event) { actor.send(self, Emit(name, event)) } diff --git a/src/lustre/internals/vdom.gleam b/src/lustre/internals/vdom.gleam index 9bb165f..5f812b2 100644 --- a/src/lustre/internals/vdom.gleam +++ b/src/lustre/internals/vdom.gleam @@ -13,6 +13,7 @@ import gleam/string_builder.{type StringBuilder} pub type Element(msg) { Text(content: String) Element( + key: String, namespace: String, tag: String, attrs: List(Attribute(msg)), @@ -44,7 +45,7 @@ fn do_handlers( case element { Text(_) -> handlers Map(subtree) -> do_handlers(subtree(), handlers, key) - Element(_, _, attrs, children, _, _) -> { + Element(_, _, _, attrs, children, _, _) -> { let handlers = list.fold(attrs, handlers, fn(handlers, attr) { case attribute_to_event_handler(attr) { @@ -55,7 +56,7 @@ fn do_handlers( }) use handlers, child, index <- list.index_fold(children, handlers) - let key = key <> int.to_string(index) + let key = key <> "-" <> int.to_string(index) do_handlers(child, handlers, key) } } @@ -71,17 +72,15 @@ fn do_element_to_json(element: Element(msg), key: String) -> Json { case element { Text(content) -> json.object([#("content", json.string(content))]) Map(subtree) -> do_element_to_json(subtree(), key) - Element(namespace, tag, attrs, children, self_closing, void) -> { + Element(_, namespace, tag, attrs, children, self_closing, void) -> { let attrs = json.preprocessed_array({ - attrs - |> list.prepend(Attribute("data-lustre-key", dynamic.from(key), False)) - |> list.filter_map(attribute_to_json(_, key)) + list.filter_map(attrs, attribute_to_json(_, key)) }) let children = json.preprocessed_array({ use child, index <- list.index_map(children) - let key = key <> int.to_string(index) + let key = key <> "-" <> int.to_string(index) do_element_to_json(child, key) }) @@ -177,7 +176,7 @@ fn do_element_to_string_builder( Map(subtree) -> do_element_to_string_builder(subtree(), raw_text) - Element(namespace, tag, attrs, _, self_closing, _) if self_closing -> { + Element(_, namespace, tag, attrs, _, self_closing, _) if self_closing -> { let html = string_builder.from_string("<" <> tag) let #(attrs, _) = attributes_to_string_builder(case namespace { @@ -190,7 +189,7 @@ fn do_element_to_string_builder( |> string_builder.append("/>") } - Element(namespace, tag, attrs, _, _, void) if void -> { + Element(_, namespace, tag, attrs, _, _, void) if void -> { let html = string_builder.from_string("<" <> tag) let #(attrs, _) = attributes_to_string_builder(case namespace { @@ -205,8 +204,8 @@ fn do_element_to_string_builder( // Style and script tags are special beacuse they need to contain unescape // text content and not escaped HTML content. - Element("", "style" as tag, attrs, children, False, False) - | Element("", "script" as tag, attrs, children, False, False) -> { + Element(_, "", "style" as tag, attrs, children, False, False) + | Element(_, "", "script" as tag, attrs, children, False, False) -> { let html = string_builder.from_string("<" <> tag) let #(attrs, _) = attributes_to_string_builder(attrs) @@ -217,7 +216,7 @@ fn do_element_to_string_builder( |> string_builder.append(" tag <> ">") } - Element(namespace, tag, attrs, children, _, _) -> { + Element(_, namespace, tag, attrs, children, _, _) -> { let html = string_builder.from_string("<" <> tag) let #(attrs, inner_html) = attributes_to_string_builder(case namespace { diff --git a/src/lustre/server_component.gleam b/src/lustre/server_component.gleam index 33123e4..749be75 100644 --- a/src/lustre/server_component.gleam +++ b/src/lustre/server_component.gleam @@ -96,12 +96,24 @@ import lustre/internals/patch /// component will be rendered inside this element. /// /// **Note**: you must include the `lustre-server-component.mjs` script found in -/// the `priv/` directory of the Lustre package in your project's HTML. +/// the `priv/` directory of the Lustre package in your project's HTML or using +/// the [`script`](#script) function. /// pub fn component(attrs: List(Attribute(msg))) -> Element(msg) { element("lustre-server-component", attrs, []) } +/// Inline the Lustre Server Component client runtime as a script tag. +/// +pub fn script() -> Element(msg) { + element("script", [attribute("type", "module")], [ + // <> + element.text( + "function k(i,n,r,s=!1){let t,l=[{prev:i,next:n,parent:i.parentNode}];for(;l.length;){let{prev:e,next:o,parent:u}=l.pop();if(o.subtree!==void 0&&(o=o.subtree()),o.content!==void 0)if(e)if(e.nodeType===Node.TEXT_NODE)e.textContent=o.content,t??=e;else{let a=document.createTextNode(o.content);u.replaceChild(a,e),t??=a}else{let a=document.createTextNode(o.content);u.appendChild(a),t??=a}else if(o.tag!==void 0){let a=$({prev:e,next:o,dispatch:r,stack:l,isComponent:s});e?e!==a&&u.replaceChild(a,e):u.appendChild(a),t??=a}}return t}function T(i,n,r){let s=i.parentNode;for(let t of n[0]){let l=t[0].split(\"-\"),e=t[1],o=N(s,l),u;if(o!==null&&o!==s)u=k(o,e,r);else{let a=N(s,l.slice(0,-1)),f=document.createTextNode(\"\");a.appendChild(f),u=k(f,e,r)}l===\"0\"&&(i=u)}for(let t of n[1]){let l=t[0].split(\"-\");N(s,l).remove()}for(let t of n[2]){let l=t[0].split(\"-\"),e=t[1],o=N(s,l),u=v.get(o);for(let a of e[0]){let f=a[0],m=a[1];if(f.startsWith(\"data-lustre-on-\")){let b=f.slice(15),d=r(J);u.has(b)||el.addEventListener(b,y),u.set(b,d),el.setAttribute(f,m)}else o.setAttribute(f,m),o[f]=m}for(let a of e[1])if(a[0].startsWith(\"data-lustre-on-\")){let f=a[0].slice(15);o.removeEventListener(f,y),u.delete(f)}else o.removeAttribute(a[0])}return i}function $({prev:i,next:n,dispatch:r,stack:s}){let t=n.namespace||\"http://www.w3.org/1999/xhtml\",l=i&&i.nodeType===Node.ELEMENT_NODE&&i.localName===n.tag&&i.namespaceURI===(n.namespace||\"http://www.w3.org/1999/xhtml\"),e=l?i:t?document.createElementNS(t,n.tag):document.createElement(n.tag),o;if(v.has(e))o=v.get(e);else{let c=new Map;v.set(e,c),o=c}let u=l?new Set(o.keys()):null,a=l?new Set(Array.from(i.attributes,c=>c.name)):null,f=null,m=null,b=null;for(let c of n.attrs){let h=c[0],p=c[1];if(c[2])e[h]=p;else if(h.startsWith(\"on\")){let g=h.slice(2),A=r(p);o.has(g)||e.addEventListener(g,y),o.set(g,A),l&&u.delete(g)}else if(h.startsWith(\"data-lustre-on-\")){let g=h.slice(15),A=r(J);o.has(g)||e.addEventListener(g,y),o.set(g,A),e.setAttribute(h,p)}else h===\"class\"?f=f===null?p:f+\" \"+p:h===\"style\"?m=m===null?p:m+p:h===\"dangerous-unescaped-html\"?b=p:(e.setAttribute(h,p),e[h]=p,l&&a.delete(h))}if(f!==null&&(e.setAttribute(\"class\",f),l&&a.delete(\"class\")),m!==null&&(e.setAttribute(\"style\",m),l&&a.delete(\"style\")),l){for(let c of a)e.removeAttribute(c);for(let c of u)e.removeEventListener(c,y)}if(n.key!==void 0&&n.key!==\"\")e.setAttribute(\"data-lustre-key\",n.key);else if(b!==null)return e.innerHTML=b,e;let d=i?.firstChild,C=null,w=null,O=null,E=n.children[Symbol.iterator]().next().value;E!==void 0&&E.key!==void 0&&E.key!==\"\"&&(C=new Set,w=L(i),O=L(n));for(let c of n.children)if(c.key!==void 0&&C!==null){for(;d&&!O.has(d.getAttribute(\"data-lustre-key\"));){let p=d.nextSibling;e.removeChild(d),d=p}if(w.size===0){s.unshift({prev:d,next:c,parent:e}),d=d?.nextSibling;continue}if(C.has(c.key)){console.warn(`Duplicate key found in Lustre vnode: ${c.key}`),s.unshift({prev:null,next:c,parent:e});continue}C.add(c.key);let h=w.get(c.key);if(!h&&!d){s.unshift({prev:null,next:c,parent:e});continue}if(!h&&d!==null){let p=document.createTextNode(\"\");e.insertBefore(p,d),s.unshift({prev:p,next:c,parent:e});continue}if(!h||h===d){s.unshift({prev:d,next:c,parent:e}),d=d?.nextSibling;continue}e.insertBefore(h,d),s.unshift({prev:h,next:c,parent:e})}else s.unshift({prev:d,next:c,parent:e}),d=d?.nextSibling;for(;d;){let c=d.nextSibling;e.removeChild(d),d=c}return e}var v=new WeakMap;function y(i){if(!v.has(i.target)){i.target.removeEventListener(i.type,y);return}let n=v.get(i.target);if(!n.has(i.type)){i.target.removeEventListener(i.type,y);return}n.get(i.type)(i)}function J(i){let n=i.target,r=n.getAttribute(`data-lustre-on-${i.type}`),s=JSON.parse(n.getAttribute(\"data-lustre-data\")||\"{}\"),t=JSON.parse(n.getAttribute(\"data-lustre-include\")||\"[]\");switch(i.type){case\"input\":case\"change\":t.push(\"target.value\");break}return{tag:r,data:t.reduce((l,e)=>{let o=e.split(\".\");for(let u=0,a=l,f=i;u{let r=[];for(let s of n)if(s.type===\"attributes\"){let{attributeName:t,oldValue:l}=s,e=this.getAttribute(t);if(l!==e)try{r.push([t,JSON.parse(e)])}catch{r.push([t,e])}}r.length&&this.#e?.send(JSON.stringify([5,r]))})}connectedCallback(){this.#t=document.createElement(\"div\"),this.appendChild(this.#t)}attributeChangedCallback(n,r,s){switch(n){case\"route\":if(!s)this.#e?.close(),this.#e=null;else if(r!==s){let t=this.getAttribute(\"id\"),l=s+(t?`?id=${t}`:\"\");this.#e?.close(),this.#e=new WebSocket(`ws://${window.location.host}${l}`),this.#e.addEventListener(\"message\",e=>this.messageReceivedCallback(e))}}}messageReceivedCallback({data:n}){let[r,...s]=JSON.parse(n);switch(r){case 0:return this.diff(s);case 1:return this.emit(s);case 2:return this.init(s)}}init([n,r]){let s=[];for(let t of n)t in this?s.push([t,this[t]]):this.hasAttribute(t)&&s.push([t,this.getAttribute(t)]),Object.defineProperty(this,t,{get(){return this[`_${t}`]??this.getAttribute(t)},set(l){let e=this[t];typeof l==\"string\"?this.setAttribute(t,l):this[`_${t}`]=l,e!==l&&this.#e?.send(JSON.stringify([5,[[t,l]]]))}});this.#n.observe(this,{attributeFilter:n,attributeOldValue:!0,attributes:!0,characterData:!1,characterDataOldValue:!1,childList:!1,subtree:!1}),this.morph(r),s.length&&this.#e?.send(JSON.stringify([5,s]))}morph(n){this.#t=k(this.#t,n,r=>s=>{let t=r(s);this.#e?.send(JSON.stringify([4,t.tag,t.data]))})}diff([n]){this.#t=T(this.#t,n,r=>s=>{let t=r(s);this.#e?.send(JSON.stringify([4,t.tag,t.data]))})}emit([n,r]){this.dispatchEvent(new CustomEvent(n,{detail:r}))}disconnectedCallback(){this.#e?.close()}};window.customElements.define(\"lustre-server-component\",S);export{S as LustreServerComponent};", + ), + ]) +} + // ATTRIBUTES ------------------------------------------------------------------ /// The `route` attribute tells the client runtime what route it should use to diff --git a/src/runtime.ffi.mjs b/src/runtime.ffi.mjs deleted file mode 100644 index f7b711b..0000000 --- a/src/runtime.ffi.mjs +++ /dev/null @@ -1,344 +0,0 @@ -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(" ") - ); -} - -// 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), - }; -} diff --git a/src/server-component.mjs b/src/server-component.mjs index 373f4cd..ab08801 100644 --- a/src/server-component.mjs +++ b/src/server-component.mjs @@ -136,13 +136,15 @@ export class LustreServerComponent extends HTMLElement { } morph(vdom) { - this.#root = morph(this.#root, vdom, (msg) => { + this.#root = morph(this.#root, vdom, (handler) => (event) => { + const msg = handler(event); this.#socket?.send(JSON.stringify([Constants.event, msg.tag, msg.data])); }); } diff([diff]) { - this.#root = patch(this.#root, diff, (msg) => { + this.#root = patch(this.#root, diff, (handler) => (event) => { + const msg = handler(event); this.#socket?.send(JSON.stringify([Constants.event, msg.tag, msg.data])); }); } 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 ``, `