aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorHayleigh Thompson <me@hayleigh.dev>2024-03-31 10:56:36 +0100
committerGitHub <noreply@github.com>2024-03-31 10:56:36 +0100
commitf45179f9124fb002e910afb618911c79a4a1549f (patch)
tree64aa1190458124a5556481c4d4b0bcb8db7c1c6b /src
parentb6aea6702d762986a69f5660df78459ef81a2e9b (diff)
downloadlustre-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.mjs5
-rw-r--r--src/client-runtime.ffi.mjs21
-rw-r--r--src/lustre.gleam10
-rw-r--r--src/lustre/effect.gleam6
-rw-r--r--src/lustre/element.gleam75
-rw-r--r--src/lustre/internals/patch.gleam15
-rw-r--r--src/lustre/internals/runtime.gleam8
-rw-r--r--src/lustre/internals/vdom.gleam23
-rw-r--r--src/lustre/server_component.gleam14
-rw-r--r--src/runtime.ffi.mjs344
-rw-r--r--src/server-component.mjs6
-rw-r--r--src/vdom.ffi.mjs696
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;
+}