From f45179f9124fb002e910afb618911c79a4a1549f Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Sun, 31 Mar 2024 10:56:36 +0100 Subject: =?UTF-8?q?=F0=9F=94=80=20Refactor=20vdom=20and=20add=20support=20?= =?UTF-8?q?for=20keyed=20vnodes.=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :truck: Keep around old vdom implementation for reference. * :sparkles: Add a keyed vdom node. * :alembic: Experiment with a different approach for handling component children. * :construction: Here be scary works in progress. * :sparkles: Implement keyed node diffing. * :recycle: Don't use deprecated 'isOk' checks. * :recycle: Remove separate Keyed node and add 'key' field to Element node. * :sparkles: Add support for server components into new vdom. * :bug: Fix broken build script. * :recycle: Don't emit data-lustre-key attributes for server component patches. * :package: Generate server component runtime. * :bug: Fixed a bug where server component keys were ambiguous when double digit. * :package: Generate server component runtime. * :recycle: Refactor 'keyed' element to force all children of a node to be keyed. * :memo: Consistently format '**Note**:'. * :bug: Fixed bug with falsey className/style/innerHTML attributes. * :bug: Fixed a bug not handling undefined 'prev' nodes correctly. * :package: Generate server component runtime. --- src/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 +++++++++++++++++++++---------------- 12 files changed, 536 insertions(+), 687 deletions(-) delete mode 100644 src/runtime.ffi.mjs (limited to 'src') 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 ``, `