From 48b04d9dc06f3f6190c8aafcb85ca12737634234 Mon Sep 17 00:00:00 2001 From: Jacob Scearcy Date: Fri, 19 Apr 2024 19:46:34 +1000 Subject: =?UTF-8?q?=F0=9F=94=80=20Add=20support=20for=20element=20fragment?= =?UTF-8?q?s.=20(#99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #16 add fragment POC * WIP * 16 add fragment support to morph * add keys for fragments * ensure proper fragment order, refactor to handle fragment/element more similarly * fix comment typo, incorrect child typo, simplify key check * fix comment typo * add snapshot tests * flatten fragment using fold right, appending elements * doc update --------- Co-authored-by: Hayleigh Thompson --- src/lustre/element.gleam | 47 +++++++++- src/lustre/internals/patch.gleam | 52 ++++++++--- src/lustre/internals/vdom.gleam | 34 +++++-- src/vdom.ffi.mjs | 190 +++++++++++++++++++++++---------------- 4 files changed, 222 insertions(+), 101 deletions(-) (limited to 'src') diff --git a/src/lustre/element.gleam b/src/lustre/element.gleam index 392a777..9fcff25 100644 --- a/src/lustre/element.gleam +++ b/src/lustre/element.gleam @@ -8,11 +8,12 @@ // IMPORTS --------------------------------------------------------------------- +import gleam/int import gleam/list import gleam/string import gleam/string_builder.{type StringBuilder} import lustre/attribute.{type Attribute, attribute} -import lustre/internals/vdom.{Element, Map, Text} +import lustre/internals/vdom.{Element, Fragment, Map, Text} // TYPES ----------------------------------------------------------------------- @@ -62,7 +63,7 @@ pub type Element(msg) = /// from your own Lustre components or from external JavaScript libraries. /// /// **Note**: Because Lustre is primarily used to create HTML, this function -/// spcieal-cases the following tags render as +/// special-cases the following tags which render as /// [void elements](https://developer.mozilla.org/en-US/docs/Glossary/Void_element): /// /// - area @@ -181,6 +182,21 @@ fn do_keyed(el: Element(msg), key: String) -> Element(msg) { void: void, ) Map(subtree) -> Map(fn() { do_keyed(subtree(), key) }) + Fragment(elements, _) -> + elements + |> list.index_map(fn(element, idx) { + case element { + Element(el_key, _, _, _, _, _, _) -> { + let new_key = case el_key { + "" -> key <> "-" <> int.to_string(idx) + _ -> key <> "-" <> el_key + } + do_keyed(element, new_key) + } + _ -> do_keyed(element, key) + } + }) + |> Fragment(key) _ -> el } } @@ -246,6 +262,30 @@ pub fn none() -> Element(msg) { Text("") } +/// A function for wrapping elements to be rendered within a parent container without +/// specififying the container on definition. Allows the treatment of List(Element(msg)) +/// as if it were Element(msg). Useful when generating a list of elements from data but +/// used downstream. +/// +pub fn fragment(elements: List(Element(msg))) -> Element(msg) { + // remove redundant fragments to simplify rendering + flatten_fragment_elements(elements) + |> Fragment("") +} + +fn flatten_fragment_elements(elements: List(Element(msg))) { + list.fold_right(elements, [], fn(new_elements, element) { + case element { + // Only flatten one level, the runtime handles next level children + // alternatively, this could flatten deeply, but it doesn't save + // iteration later given a fragment is iterated the same as an equivalent + // list of children + Fragment(fr_elements, _) -> list.append(fr_elements, new_elements) + el -> [el, ..new_elements] + } + }) +} + fn escape(escaped: String, content: String) -> String { case content { "<" <> rest -> escape(escaped <> "<", rest) @@ -286,6 +326,9 @@ pub fn map(element: Element(a), f: fn(a) -> b) -> Element(b) { void: void, ) }) + Fragment(elements, key) -> { + Map(fn() { Fragment(list.map(elements, map(_, f)), key) }) + } } } diff --git a/src/lustre/internals/patch.gleam b/src/lustre/internals/patch.gleam index 5b05429..60be771 100644 --- a/src/lustre/internals/patch.gleam +++ b/src/lustre/internals/patch.gleam @@ -11,7 +11,7 @@ import gleam/set.{type Set} import gleam/string import lustre/internals/constants import lustre/internals/vdom.{ - type Attribute, type Element, Attribute, Element, Event, Map, Text, + type Attribute, type Element, Attribute, Element, Event, Fragment, Map, Text, } // TYPES ----------------------------------------------------------------------- @@ -120,13 +120,7 @@ fn do_elements( handlers: handlers, ) - // This local `zip` function takes two lists of potentially different - // 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) - - do_elements(diff, old, new, key) + do_element_list(diff, old_children, new_children, key) } // When we have two elements, but their namespaces or their tags differ, @@ -138,11 +132,35 @@ fn do_elements( created: dict.insert(diff.created, key, new), handlers: fold_event_handlers(diff.handlers, new, key), ) + Fragment(old_elements, _), Fragment(new_elements, _) -> + do_element_list(diff, old_elements, new_elements, key) + // Other element is not a fragment, take new element in both cases + _, Fragment(_, _) | Fragment(_, _), _ -> + ElementDiff( + ..diff, + created: dict.insert(diff.created, key, new), + handlers: fold_event_handlers(diff.handlers, new, key), + ) } } } } +fn do_element_list( + diff: ElementDiff(msg), + old_elements: List(Element(msg)), + new_elements: List(Element(msg)), + key: String, +) { + // This local `zip` function takes two lists of potentially different + // sizes and zips them together, padding the shorter list with `None`. + let children = zip(old_elements, new_elements) + use diff, #(old, new), pos <- list.index_fold(children, diff) + let key = key <> "-" <> int.to_string(pos) + + do_elements(diff, old, new, key) +} + pub fn attributes( old: List(Attribute(msg)), new: List(Attribute(msg)), @@ -356,14 +374,24 @@ fn fold_event_handlers( Error(_) -> handlers } }) - use handlers, child, index <- list.index_fold(children, handlers) - let key = key <> "-" <> int.to_string(index) - - fold_event_handlers(handlers, child, key) + fold_element_list_event_handlers(handlers, children, key) } + Fragment(elements, _) -> + fold_element_list_event_handlers(handlers, elements, key) } } +fn fold_element_list_event_handlers( + handlers: Dict(String, Decoder(msg)), + elements: List(Element(msg)), + key: String, +) { + use handlers, element, index <- list.index_fold(elements, handlers) + let key = key <> int.to_string(index) + + fold_event_handlers(handlers, element, key) +} + pub fn is_empty_element_diff(diff: ElementDiff(msg)) -> Bool { diff.created == dict.new() && diff.removed == set.new() diff --git a/src/lustre/internals/vdom.gleam b/src/lustre/internals/vdom.gleam index 5f812b2..fc4a4a3 100644 --- a/src/lustre/internals/vdom.gleam +++ b/src/lustre/internals/vdom.gleam @@ -24,6 +24,7 @@ pub type Element(msg) { // The lambda here defers the creation of the mapped subtree until it is necessary. // This means we pay the cost of mapping multiple times only *once* during rendering. Map(subtree: fn() -> Element(msg)) + Fragment(elements: List(Element(msg)), key: String) } pub type Attribute(msg) { @@ -55,13 +56,22 @@ fn do_handlers( } }) - use handlers, child, index <- list.index_fold(children, handlers) - let key = key <> "-" <> int.to_string(index) - do_handlers(child, handlers, key) + do_element_list_handlers(children, handlers, key) } + Fragment(elements, _) -> do_element_list_handlers(elements, handlers, key) } } +fn do_element_list_handlers( + elements: List(Element(msg)), + handlers: Dict(String, Decoder(msg)), + key: String, +) { + use handlers, element, index <- list.index_fold(elements, handlers) + let key = key <> int.to_string(index) + do_handlers(element, handlers, key) +} + // CONVERSIONS: JSON ----------------------------------------------------------- pub fn element_to_json(element: Element(msg)) -> Json { @@ -77,12 +87,7 @@ fn do_element_to_json(element: Element(msg), key: String) -> Json { json.preprocessed_array({ 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) - do_element_to_json(child, key) - }) + let children = do_element_list_to_json(children, key) json.object([ #("namespace", json.string(namespace)), @@ -93,9 +98,18 @@ fn do_element_to_json(element: Element(msg), key: String) -> Json { #("void", json.bool(void)), ]) } + Fragment(elements, _) -> do_element_list_to_json(elements, key) } } +fn do_element_list_to_json(elements: List(Element(msg)), key: String) { + json.preprocessed_array({ + use element, index <- list.index_map(elements) + let key = key <> int.to_string(index) + do_element_to_json(element, key) + }) +} + pub fn attribute_to_json( attribute: Attribute(msg), key: String, @@ -237,6 +251,8 @@ fn do_element_to_string_builder( |> string_builder.append(">" <> inner_html <> " tag <> ">") } } + Fragment(elements, _) -> + children_to_string_builder(string_builder.new(), elements, raw_text) } } diff --git a/src/vdom.ffi.mjs b/src/vdom.ffi.mjs index 9e2a284..825a450 100644 --- a/src/vdom.ffi.mjs +++ b/src/vdom.ffi.mjs @@ -66,6 +66,15 @@ export function morph(prev, next, dispatch, isComponent = false) { } out ??= created; + // If this happens, then the top level Element is a Fragment `prev` should be + // the first element of the given fragment. Functionally, a fragment as the + // first child means that document -> body will be the parent of the first level + // of children + } else if (next.elements !== undefined) { + iterateElement(next, (fragmentElement) => { + stack.unshift({ prev, next: fragmentElement, parent }); + prev = prev?.nextSibling + }); } } @@ -334,85 +343,18 @@ function createElementNode({ prev, next, dispatch, stack }) { keyedChildren = getKeyedChildren(prev); incomingKeyedChildren = getKeyedChildren(next); } - 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; - } - - // 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; - } - - // 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; - } - - // 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; - } - - // 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; - } - - // 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 }); + iterateElement(child, (currElement) => { + // 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 (currElement.key !== undefined && seenKeys !== null) { + prevChild = diffKeyedChild(prevChild, currElement, el, stack, incomingKeyedChildren, keyedChildren, seenKeys); + } else { + stack.unshift({ prev: prevChild, next: currElement, parent: el }); prevChild = prevChild?.nextSibling; - continue; } - - // 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; - } + }); } // Any remaining children in the previous render can be removed at this point. @@ -490,8 +432,10 @@ function getKeyedChildren(el) { if (el) { for (const child of el.children) { - const key = child.key || child?.getAttribute("data-lustre-key"); - if (key) keyedChildren.set(key, child); + iterateElement(child, (currElement) => { + const key = currElement?.key || currElement?.getAttribute?.("data-lustre-key"); + if (key) keyedChildren.set(key, currElement); + }); } } @@ -510,3 +454,93 @@ function getDeepChild(el, path) { return child; } + +function diffKeyedChild(prevChild, child, el, stack, incomingKeyedChildren, keyedChildren, seenKeys) { + // 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; + } + + // 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) { + iterateElement(child, (currChild) => { + stack.unshift({ prev: prevChild, next: currChild, parent: el }); + prevChild = prevChild?.nextSibling + }); + return prevChild; + } + + // 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 }); + return prevChild; + } + + // 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 }); + return prevChild; + } + + // 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 }); + return prevChild; + } + + // 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; + return prevChild; + } + + // 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 }); + return prevChild; +} + +/* + Iterate element, helper to apply the same functions to a standard "Element" or "Fragment" transparently + 1. If single element, call callback for that element + 2. If fragment, call callback for every child element. Fragment constructor guarantees no Fragment children +*/ +function iterateElement(element, processElement) { + if (element.elements !== undefined) { + for (const currElement of element.elements) { + processElement(currElement); + } + } else { + processElement(element); + } +} -- cgit v1.2.3