diff options
author | Hayleigh Thompson <me@hayleigh.dev> | 2024-03-31 10:56:36 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-31 10:56:36 +0100 |
commit | f45179f9124fb002e910afb618911c79a4a1549f (patch) | |
tree | 64aa1190458124a5556481c4d4b0bcb8db7c1c6b /src | |
parent | b6aea6702d762986a69f5660df78459ef81a2e9b (diff) | |
download | lustre-f45179f9124fb002e910afb618911c79a4a1549f.tar.gz lustre-f45179f9124fb002e910afb618911c79a4a1549f.zip |
🔀 Refactor vdom and add support for keyed vnodes. (#83)
* :truck: Keep around old vdom implementation for reference.
* :sparkles: Add a keyed vdom node.
* :alembic: Experiment with a different approach for handling component children.
* :construction: Here be scary works in progress.
* :sparkles: Implement keyed node diffing.
* :recycle: Don't use deprecated 'isOk' checks.
* :recycle: Remove separate Keyed node and add 'key' field to Element node.
* :sparkles: Add support for server components into new vdom.
* :bug: Fix broken build script.
* :recycle: Don't emit data-lustre-key attributes for server component patches.
* :package: Generate server component runtime.
* :bug: Fixed a bug where server component keys were ambiguous when double digit.
* :package: Generate server component runtime.
* :recycle: Refactor 'keyed' element to force all children of a node to be keyed.
* :memo: Consistently format '**Note**:'.
* :bug: Fixed bug with falsey className/style/innerHTML attributes.
* :bug: Fixed a bug not handling undefined 'prev' nodes correctly.
* :package: Generate server component runtime.
Diffstat (limited to 'src')
-rw-r--r-- | src/client-component.ffi.mjs | 5 | ||||
-rw-r--r-- | src/client-runtime.ffi.mjs | 21 | ||||
-rw-r--r-- | src/lustre.gleam | 10 | ||||
-rw-r--r-- | src/lustre/effect.gleam | 6 | ||||
-rw-r--r-- | src/lustre/element.gleam | 75 | ||||
-rw-r--r-- | src/lustre/internals/patch.gleam | 15 | ||||
-rw-r--r-- | src/lustre/internals/runtime.gleam | 8 | ||||
-rw-r--r-- | src/lustre/internals/vdom.gleam | 23 | ||||
-rw-r--r-- | src/lustre/server_component.gleam | 14 | ||||
-rw-r--r-- | src/runtime.ffi.mjs | 344 | ||||
-rw-r--r-- | src/server-component.mjs | 6 | ||||
-rw-r--r-- | src/vdom.ffi.mjs | 696 |
12 files changed, 536 insertions, 687 deletions
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 `<my-component>` /// 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")], [ + // <<INJECT RUNTIME>> + 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<o.length;u++)u===o.length-1?a[o[u]]=f[o[u]]:(a[o[u]]??={},f=f[o[u]],a=a[o[u]]);return l},{data:s})}}function L(i){let n=new Map;if(i)for(let r of i.children){let s=r.key||r?.getAttribute(\"data-lustre-key\");s&&n.set(s,r)}return n}function N(i,n){let r,s,t=i;for(;[r,...s]=n,r!==void 0;)t=t.childNodes.item(r),n=s;return t}var S=class extends HTMLElement{static get observedAttributes(){return[\"route\"]}#n=null;#t=null;#e=null;constructor(){super(),this.#n=new MutationObserver(n=>{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 `<input>`, `<option>` and `<textarea>` elements +// like morphdom does? Things seem to be working as expected so far. +// + +function createElementNode({ prev, next, dispatch, stack }) { + const namespace = next.namespace || "http://www.w3.org/1999/xhtml"; + // When we morph a node we keep the previous one alive in the DOM tree and just + // update its attributes and children. + const canMorph = + prev && + prev.nodeType === Node.ELEMENT_NODE && + prev.localName === next.tag && + prev.namespaceURI === (next.namespace || "http://www.w3.org/1999/xhtml"); + const el = canMorph + ? prev + : namespace + ? document.createElementNS(namespace, next.tag) + : document.createElement(next.tag); + + // We keep track of all event handlers registered on an element across renders. + // If this is the first time we're rendering this element, or we're morphing a + // DOM node we didn't create then we need to set up that `Map` now. + let handlersForEl; + if (!registeredHandlers.has(el)) { + const emptyHandlers = new Map(); + registeredHandlers.set(el, emptyHandlers); + handlersForEl = emptyHandlers; + } else { + handlersForEl = registeredHandlers.get(el); + } - 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 we're morphing an element we need to know what event handlers and attributes + // were set on the previous render so we can clean up after ourselves. + const prevHandlers = canMorph ? new Set(handlersForEl.keys()) : null; + const prevAttributes = canMorph + ? new Set(Array.from(prev.attributes, (a) => a.name)) + : null; + + // We handle these three attributes differently because they're special. + // `class` and `style` because we want to _accumulate_ them, and `innerHTML` + // because it's a special Lustre attribute that allows you to render a HTML + // string directly into an element. + let className = null; + let style = null; + let innerHTML = null; + + // In Gleam custom type fields have numeric indexes if they aren't labelled + // but they *aren't* able to be destructured, so we have to do normal array + // access below. + for (const attr of next.attrs) { + const name = attr[0]; + const value = attr[1]; + const isProperty = attr[2]; + + // Properties are set directly on the DOM node. + if (isProperty) { + el[name] = value; } - } + // Event handlers require some special treatment. We have a generic event + // handler that is used for all event handlers attached by lustre. This way + // we aren't removing and adding event listeners every render: for each type + // of event we attach an event listener just once (until it is removed) and + // subsequent renders swap out the callback stored in `handlersForEl`. + else if (name.startsWith("on")) { + const eventName = name.slice(2); + const callback = dispatch(value); + + if (!handlersForEl.has(eventName)) { + el.addEventListener(eventName, lustreGenericEventHandler); + } - if (customElements.get(curr.tag)) { - el._slot = curr.children; - } else if (curr.tag === "slot") { - let children = new Empty(); - let parentWithSlot = parent; + handlersForEl.set(eventName, callback); + // If we're morphing an element we remove this event's name from the set of + // event handlers that were on the previous render so we don't remove it in + // the next step. + if (canMorph) prevHandlers.delete(eventName); + } + // + else if (name.startsWith("data-lustre-on-")) { + const eventName = name.slice(15); + const callback = dispatch(lustreServerEventHandler); - while (parentWithSlot) { - if (parentWithSlot._slot) { - children = parentWithSlot._slot; - break; - } else { - parentWithSlot = parentWithSlot.parentNode; + if (!handlersForEl.has(eventName)) { + el.addEventListener(eventName, lustreGenericEventHandler); } - } - for (const child of children) { - el.appendChild(morph(null, child, dispatch, el)); + handlersForEl.set(eventName, callback); + el.setAttribute(name, value); } - } else if (dangerousUnescapedHtml) { - el.innerHTML = dangerousUnescapedHtml; - } else { - for (const child of curr.children) { - el.appendChild(morph(null, child, dispatch, el)); + // These attributes are special-cased as explained above. + 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; + } + // Everything else is treated as a normal attribute. Because we can't easily + // tell which attributes are mirrored as properties on the DOM node we assume + // that all attributes should be set as properties too. + else { + el.setAttribute(name, value); + el[name] = value; + // If we're morphing an element we remove this attribute's name from the set + // of attributes that were on the previous render so we don't remove it in + // the next step. + if (canMorph) prevAttributes.delete(name); } } - if (prev) prev.replaceWith(el); - - return el; -} + if (className !== null) { + el.setAttribute("class", className); + if (canMorph) prevAttributes.delete("class"); + } -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]); - } + if (style !== null) { + el.setAttribute("style", style); + if (canMorph) prevAttributes.delete("style"); } - for (const { name } of prevAttrs) { - if (!currAttrs.has(name)) { - prev.removeAttribute(name); - } else { - const value = currAttrs.get(name); + if (canMorph) { + for (const attr of prevAttributes) { + el.removeAttribute(attr); + } - morphAttr(prev, name, value, dispatch); - currAttrs.delete(name); + for (const eventName of prevHandlers) { + el.removeEventListener(eventName, lustreGenericEventHandler); } } - 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); + // Keyed elements have the property explicitly set on the DOM so we can easily + // find them for subsequent renders. We do this after attributes have been + // morphed in case someone has keyed an element *and* set the attribute themselves. + // + // The key stored on the vdom node always takes precedence over the attribute. + if (next.key !== undefined && next.key !== "") { + el.setAttribute("data-lustre-key", next.key); + } - delete prev.$lustre[name]; - delete prev.$lustre[`${name}Handler`]; - } + // If we have an `innerHTML` string then we don't bother morphing the children + // at all, we just set the `innerHTML` property and move on. + else if (innerHTML !== null) { + el.innerHTML = innerHTML; + return el; } - for (const [name, value] of currAttrs) { - morphAttr(prev, name, value, dispatch); + let prevChild = prev?.firstChild; + // These variables are set up for keyed children diffs. When children are keyed + // we can effeciently reuse DOM nodes even if they've moved around in the list. + let seenKeys = null; + let keyedChildren = null; + let incomingKeyedChildren = null; + let firstChild = next.children[Symbol.iterator]().next().value; + + // All children are expected to be keyed if any of them are keyed, so just peeking + // the first child is enough to determine if we need to do a keyed diff. + if ( + firstChild !== undefined && + // 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 !== undefined && + firstChild.key !== "" + ) { + seenKeys = new Set(); + keyedChildren = getKeyedChildren(prev); + incomingKeyedChildren = getKeyedChildren(next); } - 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; + for (const child of next.children) { + // A keyed morph has more complex logic to handle: we need to be grabbing + // same-key nodes from the previous render and moving them to the correct + // position in the DOM. + if (child.key !== undefined && seenKeys !== null) { + // If the existing child doesn't have a key, or it is keyed but not present + // in the incoming children, then we remove it. We keep doing this until we + // find a keyed child that we should preserve and then move on with the + // morph as normal. + while ( + prevChild && + !incomingKeyedChildren.has(prevChild.getAttribute("data-lustre-key")) + ) { + const nextChild = prevChild.nextSibling; + el.removeChild(prevChild); + prevChild = nextChild; } - } - 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; + // If there were no keyed children in the previous render then we can just + // insert the incoming child at the current position (and diff against whatever + // is already there). + if (keyedChildren.size === 0) { + stack.unshift({ prev: prevChild, next: child, parent: el }); + prevChild = prevChild?.nextSibling; + continue; } - 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; + // If we come across a child that has the same key as something else this + // render then we can't do any meaningful morphing. We throw a warning and + // fall back to inserting the new node. + if (seenKeys.has(child.key)) { + console.warn(`Duplicate key found in Lustre vnode: ${child.key}`); + stack.unshift({ prev: null, next: child, parent: el }); + continue; } - } - - 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; + // The `seenKeys` set is how we track duplicate keys. + seenKeys.add(child.key); + // If we make it this far then there is potentially a keyed child we can + // reuse from the previous render. + const keyedChild = keyedChildren.get(child.key); + + // This case is hit when the previous render had *no* children at all. In + // that case we can just insert the incoming child. + if (!keyedChild && !prevChild) { + stack.unshift({ prev: null, next: child, parent: el }); + continue; } - if (el.hasAttribute(name)) break; - - const event = name.slice(15).toLowerCase(); - const handler = dispatch(serverEventHandler); - if (el.$lustre[`${name}Handler`]) { - el.removeEventListener(event, el.$lustre[`${name}Handler`]); + // This is a new keyed child that wasn't in the previous render. Because we + // can't guarantee things won't get moved around we insert a placeholder node + // that preserves the position of the incoming child. + if (!keyedChild && prevChild !== null) { + const placeholder = document.createTextNode(""); + el.insertBefore(placeholder, prevChild); + stack.unshift({ prev: placeholder, next: child, parent: el }); + continue; } - 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 (name === "value") el.value = value; - el.setAttribute(name, 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 = dispatch(value); - - if (el.$lustre[`${name}Handler`]) { - el.removeEventListener(event, el.$lustre[`${name}Handler`]); + // This is the same as the unkeyed case: we don't have to do any special + // handling, just diff against the previous child and move on. + if (!keyedChild || keyedChild === prevChild) { + stack.unshift({ prev: prevChild, next: child, parent: el }); + prevChild = prevChild?.nextSibling; + continue; } - el.addEventListener(event, handler); - - el.$lustre[name] = value; - el.$lustre[`${name}Handler`] = handler; - el.$lustre.__registered_events.add(name); - - break; + // If we get this far then we did find a keyed child to diff against but + // it's somewhere else in the tree. This `insertBefore` moves the old child + // into the correct position. + // + // Note that we're *not* updating the `prevChild` pointer. + el.insertBefore(keyedChild, prevChild); + stack.unshift({ prev: keyedChild, next: child, parent: el }); + } else { + stack.unshift({ prev: prevChild, next: child, parent: el }); + prevChild = prevChild?.nextSibling; } - - default: - el[name] = value; } -} - -// TEXT ------------------------------------------------------------------------ -function createText(prev, curr) { - const el = document.createTextNode(curr.content); + // Any remaining children in the previous render can be removed at this point. + while (prevChild) { + const next = prevChild.nextSibling; + el.removeChild(prevChild); + prevChild = next; + } - if (prev) prev.replaceWith(el); return el; } -function morphText(prev, curr) { - const prevValue = prev.nodeValue; - const currValue = curr.content; +// EVENT HANDLERS -------------------------------------------------------------- + +const registeredHandlers = new WeakMap(); - if (!currValue) { - prev?.remove(); - return null; +function lustreGenericEventHandler(event) { + if (!registeredHandlers.has(event.target)) { + event.target.removeEventListener(event.type, lustreGenericEventHandler); + return; } - if (prevValue !== currValue) prev.nodeValue = currValue; + const handlersForEventTarget = registeredHandlers.get(event.target); - return prev; -} + if (!handlersForEventTarget.has(event.type)) { + event.target.removeEventListener(event.type, lustreGenericEventHandler); + return; + } -// UTILS ----------------------------------------------------------------------- + handlersForEventTarget.get(event.type)(event); +} -function serverEventHandler(event) { +function lustreServerEventHandler(event) { const el = event.target; const tag = el.getAttribute(`data-lustre-on-${event.type}`); const data = JSON.parse(el.getAttribute("data-lustre-data") || "{}"); @@ -407,3 +479,31 @@ function serverEventHandler(event) { ), }; } + +// UTILS ----------------------------------------------------------------------- + +function getKeyedChildren(el) { + const keyedChildren = new Map(); + + if (el) { + for (const child of el.children) { + const key = child.key || child?.getAttribute("data-lustre-key"); + if (key) keyedChildren.set(key, child); + } + } + + return keyedChildren; +} + +function getDeepChild(el, path) { + let n; + let rest; + let child = el; + + while ((([n, ...rest] = path), n !== undefined)) { + child = child.childNodes.item(n); + path = rest; + } + + return child; +} |