diff --git a/project/Versions.scala b/project/Versions.scala index 24f871b6..f74d8a8f 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -20,5 +20,5 @@ object Versions { val JsDom = "16.4.0" - val ScalaDomTestUtils = "0.16.0-RC4" + val ScalaDomTestUtils = "0.16.0-SNAPSHOT" } diff --git a/src/main/scala/com/raquo/laminar/DomApi.scala b/src/main/scala/com/raquo/laminar/DomApi.scala index e8b8e135..c7c0f06f 100644 --- a/src/main/scala/com/raquo/laminar/DomApi.scala +++ b/src/main/scala/com/raquo/laminar/DomApi.scala @@ -419,4 +419,24 @@ object DomApi { } } + def debugNodeOuterHtml(node: dom.Node): String = { + node match { + case el: dom.Element => el.outerHTML + case el: dom.Text => s"Text(${el.textContent})" + case el: dom.Comment => s"Comment(${el.textContent})" + case null => "" + case other => s"OtherNode(${other.toString})" + } + } + + def debugNodeInnerHtml(node: dom.Node): String = { + node match { + case el: dom.Element => el.innerHTML + case el: dom.Text => s"Text(${el.textContent})" + case el: dom.Comment => s"Comment(${el.textContent})" + case null => "" + case other => s"OtherNode(${other.toString})" + } + } + } diff --git a/src/main/scala/com/raquo/laminar/Implicits.scala b/src/main/scala/com/raquo/laminar/Implicits.scala index 69fe8cc0..8dc6a44d 100644 --- a/src/main/scala/com/raquo/laminar/Implicits.scala +++ b/src/main/scala/com/raquo/laminar/Implicits.scala @@ -7,7 +7,7 @@ import com.raquo.laminar.Implicits.RichSource import com.raquo.laminar.api.Laminar.StyleEncoder import com.raquo.laminar.keys.CompositeKey.CompositeValueMappers import com.raquo.laminar.keys.{DerivedStyleProp, EventProcessor, EventProp} -import com.raquo.laminar.modifiers.{Binder, ChildInserter, ChildrenInserter, Inserter, Modifier, Setter} +import com.raquo.laminar.modifiers.{Binder, ChildInserter, ChildTextInserter, ChildrenInserter, Inserter, Modifier, Renderable, Setter} import com.raquo.laminar.nodes.{ChildNode, ReactiveElement, TextNode} import org.scalajs.dom @@ -27,13 +27,10 @@ trait Implicits extends Implicits.LowPriorityImplicits with CompositeValueMapper EventProcessor.empty(eventProp) } - @inline implicit def textToNode(text: String): TextNode = new TextNode(text) - - @inline implicit def boolToNode(bool: Boolean): TextNode = new TextNode(bool.toString) - - @inline implicit def intToNode(int: Int): TextNode = new TextNode(int.toString) - - @inline implicit def doubleToNode(double: Double): TextNode = new TextNode(double.toString) + /** Implicit [[Renderable]] instances are available for primitive types (defined in modifiers/Renderable.scala) */ + implicit def renderableToTextNode[A](value: A)(implicit renderable: Renderable[A]): TextNode = { + renderable.asTextNode(value) + } /** Create a setter that applies each of the setters in a seq */ implicit def seqToSetter[El <: ReactiveElement.Base](setters: scala.collection.Seq[Setter[El]]): Setter[El] = { @@ -123,11 +120,11 @@ object Implicits { // Inserter implicits are needlessly expensive if we just need a Modifier, so we de-prioritize them implicit def nodeToInserter(node: ChildNode.Base): Inserter[ReactiveElement.Base] = { - ChildInserter[ReactiveElement.Base](_ => Val(node)) + ChildInserter[ReactiveElement.Base](Val(node)) } implicit def nodesSeqToInserter(nodes: scala.collection.Seq[ChildNode.Base]): Inserter[ReactiveElement.Base] = { - ChildrenInserter[ReactiveElement.Base](_ => Val(nodes.toList)) + ChildrenInserter[ReactiveElement.Base](Val(nodes.toList)) } implicit def nodesArrayToInserter(nodes: Array[ChildNode.Base]): Inserter[ReactiveElement.Base] = { @@ -137,6 +134,10 @@ object Implicits { @inline implicit def nodesJsArrayToInserter[N <: ChildNode.Base](nodes: js.Array[N]): Inserter[ReactiveElement.Base] = { nodesSeqToInserter(nodes) } + + implicit def renderableToInserter[A](value: A)(implicit renderable: Renderable[A]): Inserter[ReactiveElement.Base] = { + ChildTextInserter[A, ReactiveElement.Base](Val(value), renderable) + } } /** Some of these methods are redundant, but we need them for type inference to work. */ diff --git a/src/main/scala/com/raquo/laminar/api/Laminar.scala b/src/main/scala/com/raquo/laminar/api/Laminar.scala index 35b829df..4c4ad877 100644 --- a/src/main/scala/com/raquo/laminar/api/Laminar.scala +++ b/src/main/scala/com/raquo/laminar/api/Laminar.scala @@ -310,12 +310,14 @@ private[laminar] object Laminar def onMountInsert[El <: Element](fn: MountContext[El] => Inserter[El]): Modifier[El] = { Modifier[El] { element => var maybeSubscription: Option[DynamicSubscription] = None - val lockedContext = InsertContext.reserveSpotContext[El](element) + // We start the context in loose mode for performance, because it's cheaper to go from there + // to strict mode, than the other way. The inserters are able to handle any initial mode. + val lockedInsertContext = InsertContext.reserveSpotContext[El](element, strictMode = false) element.amend( onMountUnmountCallback[El]( - mount = { c => - val inserter = fn(c).withContext(lockedContext) - maybeSubscription = Some(inserter.bind(c.thisNode)) + mount = { mountContext => + val inserter = fn(mountContext).withContext(lockedInsertContext) + maybeSubscription = Some(inserter.bind(mountContext.thisNode)) }, unmount = { _ => maybeSubscription.foreach(_.kill()) @@ -397,10 +399,13 @@ private[laminar] object Laminar } else { state = Some(mount(c)) } - new Subscription(c.owner, cleanup = () => { - unmount(element, state) - state = None - }) + new Subscription( + c.owner, + cleanup = () => { + unmount(element, state) + state = None + } + ) } } } diff --git a/src/main/scala/com/raquo/laminar/lifecycle/InsertContext.scala b/src/main/scala/com/raquo/laminar/lifecycle/InsertContext.scala index a4800f5f..75983602 100644 --- a/src/main/scala/com/raquo/laminar/lifecycle/InsertContext.scala +++ b/src/main/scala/com/raquo/laminar/lifecycle/InsertContext.scala @@ -1,6 +1,8 @@ package com.raquo.laminar.lifecycle import com.raquo.ew.JsMap +import com.raquo.laminar.DomApi +import com.raquo.laminar.modifiers.ChildrenInserter.Children import com.raquo.laminar.nodes.{ChildNode, CommentNode, ParentNode, ReactiveElement} import org.scalajs.dom @@ -11,26 +13,160 @@ import scala.collection.immutable // @Note only parentNode and sentinelNode are used by all Inserter-s. // - Other fields may remain un-updated if they are not needed for a particular use case. + +/** + * InsertContext represents the state of the DOM inside an inserter block like `child <-- ...`, + * `children <-- ...`, `child.text <-- ...`, etc. The data stored in this context is used + * by Laminar to efficiently update the DOM, to detect (and recover from) external changes + * to the DOM, and for other related tasks. + * + * InsertContext is a mutable data structure that is created once for each inserter, and the + * inserter updates it as it processes new data. However, in case of `onMountInsert`, only + * one context is created, and is then reused for all inserters created inside + * `onMountInsert`. This allows for intuitive preservation of DOM state if the element is + * unmounted and mounted again (for example, `onMountInsert(child <-- stream)` will + * keep the last emitted child in the DOM even if the element is unmounted and re-mounted). + * + * #Note: The params that describe `extraNodes` below can get out of sync with the real DOM. + * + * This can happen if an child element is removed from the DOM – either externally, or more + * likely because it was moved from this inserter into another one, and the addition to the + * other inserter was processed before the removal from this inserter is processed (the + * order of these operations depends on the propagation order of the observables feeding + * these two inserters). The Inserter code must account for this and not fail in such cases, + * and must correct the values accordingly on the next observable update. + * + * #Note: The params that describe `extraNodes` below must be kept consistent manually (#Perf) + * + * Inserter "steals" an element from this one just before the + * observable source of this inserter provides a new list of + * children (with the stolen element removed from the list). + * + * @param sentinelNode - A special invisible comment node that tells Laminar where to + * insert the dynamic children, and where to expect previously + * inserted dynamic children. + * @param strictMode - If true, Laminar guarantees that it will keep a dedicated + * sentinel node instead of using the extra node (content node) + * for that purpose. This is needed in order to allow users to + * move an element from one inserter to another, or to externally + * remove some of the elements previously added by an inserter. + * child.text does not need any of that, so for performance it + * does not use strict mode, it replaces the sentinel comment + * node with the subsequent text nodes. Inserters should be able + * to safely switch to their preferred mode when receiving + * context left by the previous inserter in onMountBind. + * @param extraNodeCount - Number of child nodes in addition to the sentinel node. + * Warning: can get out of sync with the real DOM! + * @param extraNodes - Ordered list of child nodes in addition to the sentinel node. + * Warning: can get out of sync with the real DOM! + * @param extraNodesMap - Map of child nodes, for more efficient search + * Warning: can get out of sync with the real DOM! + */ final class InsertContext[+El <: ReactiveElement.Base]( val parentNode: El, var sentinelNode: ChildNode.Base, + var strictMode: Boolean, var extraNodeCount: Int, // This is separate from `extraNodesMap` for performance #TODO[Performance]: Check if this is still relevant with JsMap + var extraNodes: Children, var extraNodesMap: JsMap[dom.Node, ChildNode.Base] -) +) { + + /** + * This method converts the InsertContext from loose mode to strict mode. + * ChildrenInserter and ChildInserter call this when receiving a context from + * ChildTextInserter. This can happen when switching from `child.text <-- ...` + * to e.g. `children <-- ...` inside onMountInsert. + * + * Prerequisite: context must be in loose mode, and in valid state: no extra nodes allowed. + */ + def forceSetStrictMode(): Unit = { + if (strictMode || extraNodeCount != 0) { + // #Note: if extraNodeCount == 0, it is also assumed (but not tested) that extraNodes and extraNodesMap are empty. + throw new Exception(s"forceSetStrictMode invoked when not allowed, inside parent = ${DomApi.debugNodeOuterHtml(parentNode.ref)}") + } + if (extraNodesMap == null) { + // In loose mode, extraNodesMap is likely to be null, so we need to initialize it. + extraNodesMap = new JsMap() + } + if (sentinelNode.ref.isInstanceOf[dom.Comment]) { + // This means there are no content nodes. + // We assume that all extraNode fields are properly zeroed, so there is nothing to do. + } else { + // In loose mode, child content nodes are written to sentinelNode field, + // so there are no extraNodes. + // So, if we find a content node in sentinelNode, we need to reclassify + // it as such for the strict mode, and insert a new sentinel node into the DOM. + val contentNode = sentinelNode + val newSentinelNode = new CommentNode("") + ParentNode.insertChild( + parent = parentNode, + child = newSentinelNode, + atIndex = ParentNode.indexOfChild( + parent = parentNode, + child = contentNode + ) + ) + + // Convert loose mode context values to strict mode context values + sentinelNode = newSentinelNode + extraNodeCount = 1 + extraNodes = contentNode :: Nil + extraNodesMap.set(contentNode.ref, contentNode) // we initialized the map above + } + strictMode = true + } + + /** #Note: this does NOT update the context to match the DOM. */ + def removeOldChildNodesFromDOM(after: ChildNode.Base): Unit = { + var remainingOldExtraNodeCount = extraNodeCount + while (remainingOldExtraNodeCount > 0) { + val prevChildRef = after.ref.nextSibling + if (prevChildRef == null) { + // We expected more previous children to be in the DOM, but we reached the end of the DOM. + // Those children must have been removed from the DOM manually, or moved to a different inserter. + // So, the DOM state is now correct, albeit "for the wrong reasons". All is good. End the loop. + remainingOldExtraNodeCount = 0 + } else { + val maybePrevChild = extraNodesMap.get(prevChildRef) + if (maybePrevChild.isEmpty) { + // Similar to the prevChildRef == null case above, we've exhausted the DOM, + // except we stumbled on some unrelated element instead. We only allow external + // removals from the DOM, not external additions in the middle of dynamic children list, + // so this unrelated element is good evidence that there are no more old child nodes + // to be found. + remainingOldExtraNodeCount = 0 + } else { + maybePrevChild.foreach { prevChild => + // @Note: DOM update + ParentNode.removeChild(parent = parentNode, child = prevChild) + remainingOldExtraNodeCount -= 1 + } + } + } + } + } +} object InsertContext { /** Reserve the spot for when we actually insert real nodes later */ - def reserveSpotContext[El <: ReactiveElement.Base](parentNode: El): InsertContext[El] = { + def reserveSpotContext[El <: ReactiveElement.Base]( + parentNode: El, + strictMode: Boolean + ): InsertContext[El] = { val sentinelNode = new CommentNode("") ParentNode.appendChild(parent = parentNode, child = sentinelNode) + // #Warning[Fragile] - We avoid instantiating a JsMap in loose mode, for performance. + // The JsMap is initialized if/when needed, in forceSetStrictMode. new InsertContext[El]( parentNode = parentNode, sentinelNode = sentinelNode, + strictMode = strictMode, extraNodeCount = 0, - extraNodesMap = new JsMap() + extraNodes = Nil, + extraNodesMap = if (strictMode) new JsMap() else null ) } diff --git a/src/main/scala/com/raquo/laminar/modifiers/ChildInserter.scala b/src/main/scala/com/raquo/laminar/modifiers/ChildInserter.scala index f6c551db..3263b94c 100644 --- a/src/main/scala/com/raquo/laminar/modifiers/ChildInserter.scala +++ b/src/main/scala/com/raquo/laminar/modifiers/ChildInserter.scala @@ -1,7 +1,6 @@ package com.raquo.laminar.modifiers import com.raquo.airstream.core.Observable -import com.raquo.laminar.lifecycle.{InsertContext, MountContext} import com.raquo.laminar.nodes.{ChildNode, ParentNode, ReactiveElement} import scala.scalajs.js @@ -9,21 +8,58 @@ import scala.scalajs.js object ChildInserter { def apply[El <: ReactiveElement.Base] ( - $child: MountContext[El] => Observable[ChildNode.Base] - ): Inserter[El] = new Inserter[El]( - insertFn = (ctx, owner) => { - val mountContext = new MountContext[El]( - thisNode = ctx.parentNode, - owner = owner - ) - var lastSeenChild: js.UndefOr[ChildNode.Base] = js.undefined - $child(mountContext).foreach { newChildNode => - if (!lastSeenChild.exists(_ eq newChildNode)) { // #Note: auto-distinction - lastSeenChild = newChildNode - ParentNode.replaceChild(parent = ctx.parentNode, oldChild = ctx.sentinelNode, newChild = newChildNode) - ctx.sentinelNode = newChildNode + $child: Observable[ChildNode.Base] + ): Inserter[El] = { + new Inserter[El]( + preferStrictMode = true, + insertFn = (ctx, owner) => { + if (!ctx.strictMode) { + ctx.forceSetStrictMode() } - }(owner) - } - ) + + var maybeLastSeenChild: js.UndefOr[ChildNode.Base] = js.undefined + + $child.foreach { newChildNode => + var remainingOldExtraNodeCount = ctx.extraNodeCount + + maybeLastSeenChild + .filter(_.ref == ctx.sentinelNode.ref.nextSibling) // Assert that the prev child node was not moved. Note: nextSibling could be null + .fold { + // Inserting the child for the first time, OR after the previous child was externally moved / removed. + val sentinelNodeIndex = ParentNode.indexOfChild(ctx.parentNode, ctx.sentinelNode) + ParentNode.insertChild(parent = ctx.parentNode, newChildNode, atIndex = sentinelNodeIndex + 1) + () + } { lastSeenChild => + // We found the existing child in the right place in the DOM + // Just need to check that the new child is actually different from the old one + // Replace the child with new one. + // #Note: auto-distinction inside (lastSeenChild != newChildNode filter) + val replaced = ParentNode.replaceChild( + parent = ctx.parentNode, + oldChild = lastSeenChild, + newChild = newChildNode + ) + if (replaced || lastSeenChild == newChildNode) { // #TODO[Performance,Integrity] Not liking this redundant auto-distinction + // The only time we DON'T decrement this is when replacing fails for unexpected reasons. + // - If lastSeenChild == newChildNode, then it's not an "old" node anymore, so we decrement + // - If replaced == true, then lastSeenChild was removed from the DOM, so we decrement + remainingOldExtraNodeCount -= 1 + } + () + } + + // We've just inserted newChildNode after the sentinel, or replaced the first old node with newChildNode, + // so any remaining old child nodes must be directly under it. + ctx.removeOldChildNodesFromDOM(after = newChildNode) + + ctx.extraNodesMap.clear() + ctx.extraNodesMap.set(newChildNode.ref, newChildNode) + ctx.extraNodes = newChildNode :: Nil + ctx.extraNodeCount = 1 + + maybeLastSeenChild = newChildNode + }(owner) + } + ) + } } diff --git a/src/main/scala/com/raquo/laminar/modifiers/ChildTextInserter.scala b/src/main/scala/com/raquo/laminar/modifiers/ChildTextInserter.scala new file mode 100644 index 00000000..0b786445 --- /dev/null +++ b/src/main/scala/com/raquo/laminar/modifiers/ChildTextInserter.scala @@ -0,0 +1,51 @@ +package com.raquo.laminar.modifiers + +import com.raquo.airstream.core.Observable +import com.raquo.laminar.nodes.{ParentNode, ReactiveElement, TextNode} + +import scala.scalajs.js + +object ChildTextInserter { + + def apply[A, El <: ReactiveElement.Base] ( + $text: Observable[A], + renderable: Renderable[A] + ): Inserter[El] = { + new Inserter[El]( + preferStrictMode = false, + insertFn = (ctx, owner) => { + var maybeLastSeenChild: js.UndefOr[TextNode] = js.undefined + $text.foreach { newValue => + maybeLastSeenChild.fold { + // First event: inserting the child for the first time: replace sentinel comment node with new TextNode + val newTextNode = renderable.asTextNode(newValue) + ParentNode.replaceChild(parent = ctx.parentNode, oldChild = ctx.sentinelNode, newChild = newTextNode) + maybeLastSeenChild = newTextNode + + ctx.sentinelNode = newTextNode + if (ctx.strictMode) { + ctx.strictMode = false + // We've just replaced the sentinel node with newTextNode, + // so any remaining old child nodes must be directly under it. + ctx.removeOldChildNodesFromDOM(after = newTextNode) + // In loose mode, the child content node replaces the sentinel, and is not tracked in "extraNodes". + // This is different from strict mode where the sentinel node is independent, to allow for moving + // of elements in between different inserters (otherwise Laminar would lose track of the reserved + // spot in such cases). + ctx.extraNodesMap.clear() + ctx.extraNodes = Nil + ctx.extraNodeCount = 0 + } + () + } { lastSeenChild => + // Subsequent events: updating the textContent field of the existing TextNode (which is also the sentinel node). + val newText = renderable.asString(newValue) + if (lastSeenChild.text != newText) { // #Note: auto-distinction + lastSeenChild.ref.textContent = newText + } + } + }(owner) + } + ) + } +} diff --git a/src/main/scala/com/raquo/laminar/modifiers/ChildrenCommandInserter.scala b/src/main/scala/com/raquo/laminar/modifiers/ChildrenCommandInserter.scala index 8d56c5e3..e624da26 100644 --- a/src/main/scala/com/raquo/laminar/modifiers/ChildrenCommandInserter.scala +++ b/src/main/scala/com/raquo/laminar/modifiers/ChildrenCommandInserter.scala @@ -2,31 +2,40 @@ package com.raquo.laminar.modifiers import com.raquo.airstream.core.EventStream import com.raquo.laminar.CollectionCommand -import com.raquo.laminar.lifecycle.{InsertContext, MountContext} import com.raquo.laminar.modifiers.ChildrenInserter.Child import com.raquo.laminar.nodes.{ChildNode, ParentNode, ReactiveElement} +/** + * Note: this is a low level inserter. It is the fastest one, but due to + * its rather imperative API, its usefulness is very limited. It's good for + * simple but voluminous stuff, like appending new log items to a big list, + * but not much else. + * + * Consider using `children <-- observable.split(...)` instead, it has + * great performance and is much more convenient. + */ object ChildrenCommandInserter { type ChildrenCommand = CollectionCommand[Child] def apply[El <: ReactiveElement.Base] ( - $command: MountContext[El] => EventStream[ChildrenCommand] - ): Inserter[El] = new Inserter[El]( - insertFn = (ctx, owner) => { - val mountContext = new MountContext[El](thisNode = ctx.parentNode, owner) - - $command(mountContext).foreach { command => - val nodeCountDiff = updateList( - command, - parentNode = ctx.parentNode, - sentinelNode = ctx.sentinelNode, - ctx.extraNodeCount - ) - ctx.extraNodeCount += nodeCountDiff - }(owner) - } - ) + $command: EventStream[ChildrenCommand] + ): Inserter[El] = { + new Inserter[El]( + preferStrictMode = true, + insertFn = (ctx, owner) => { + $command.foreach { command => + val nodeCountDiff = updateList( + command, + parentNode = ctx.parentNode, + sentinelNode = ctx.sentinelNode, + ctx.extraNodeCount + ) + ctx.extraNodeCount += nodeCountDiff + }(owner) + } + ) + } def updateList( command: ChildrenCommand, diff --git a/src/main/scala/com/raquo/laminar/modifiers/ChildrenInserter.scala b/src/main/scala/com/raquo/laminar/modifiers/ChildrenInserter.scala index 18990372..2201c4a3 100644 --- a/src/main/scala/com/raquo/laminar/modifiers/ChildrenInserter.scala +++ b/src/main/scala/com/raquo/laminar/modifiers/ChildrenInserter.scala @@ -2,7 +2,7 @@ package com.raquo.laminar.modifiers import com.raquo.airstream.core.Observable import com.raquo.ew.JsMap -import com.raquo.laminar.lifecycle.{InsertContext, MountContext} +import com.raquo.laminar.lifecycle.InsertContext import com.raquo.laminar.nodes.{ChildNode, ParentNode, ReactiveElement} import org.scalajs.dom @@ -11,36 +11,42 @@ import scala.scalajs.js object ChildrenInserter { - private val emptyChildren = Vector() - type Child = ChildNode.Base type Children = immutable.Seq[Child] - def apply[El <: ReactiveElement.Base] ( - $children: MountContext[El] => Observable[Children] - ): Inserter[El] = new Inserter[El]( - insertFn = (ctx, owner) => { - val mountContext = new MountContext[El](thisNode = ctx.parentNode, owner) - val childrenSignal = $children(mountContext).toSignalIfStream(_.startWith(emptyChildren)) - var lastSeenChildren: js.UndefOr[Children] = js.undefined - childrenSignal.foreach { newChildren => - if (!lastSeenChildren.exists(_ eq newChildren)) { // #Note: auto-distinction - lastSeenChildren = newChildren - val newChildrenMap = InsertContext.nodesToMap(newChildren) - ctx.extraNodeCount = updateChildren( - prevChildren = ctx.extraNodesMap, - nextChildren = newChildren, - nextChildrenMap = newChildrenMap, - parentNode = ctx.parentNode, - sentinelNode = ctx.sentinelNode, - ctx.extraNodeCount - ) - ctx.extraNodesMap = newChildrenMap + def apply[El <: ReactiveElement.Base]( + $children: Observable[Children] + ): Inserter[El] = { + new Inserter[El]( + preferStrictMode = true, + insertFn = (ctx, owner) => { + if (!ctx.strictMode) { + ctx.forceSetStrictMode() } - }(owner) - } - ) + + var maybeLastSeenChildren: js.UndefOr[Children] = ctx.extraNodes + + $children.foreach { newChildren => + if (!maybeLastSeenChildren.exists(_ eq newChildren)) { // #Note: auto-distinction + // println(s">> ${$children}.foreach with newChildren = ${newChildren.map(_.ref).map(DomApi.debugNodeOuterHtml)}") + maybeLastSeenChildren = newChildren + val newChildrenMap = InsertContext.nodesToMap(newChildren) + ctx.extraNodeCount = updateChildren( + prevChildren = ctx.extraNodesMap, + nextChildren = newChildren, + nextChildrenMap = newChildrenMap, + parentNode = ctx.parentNode, + sentinelNode = ctx.sentinelNode, + ctx.extraNodeCount + ) + ctx.extraNodes = newChildren + ctx.extraNodesMap = newChildrenMap + } + }(owner) + } + ) + } /** @return New child node count */ private def updateChildren( @@ -187,7 +193,7 @@ object ChildrenInserter { } private def prevChildFromRef(prevChildren: JsMap[dom.Node, ChildNode.Base], ref: dom.Node): Child = { - prevChildren.get(ref).get // @TODO[Integrity] Throw a meaningful error if not found (that would be unrecoverable inconsistent state) + prevChildren.get(ref).getOrElse(throw new Exception(s"prevChildFromRef[children]: not found for ${ref}")) } } diff --git a/src/main/scala/com/raquo/laminar/modifiers/Inserter.scala b/src/main/scala/com/raquo/laminar/modifiers/Inserter.scala index 93940928..7b31b34b 100644 --- a/src/main/scala/com/raquo/laminar/modifiers/Inserter.scala +++ b/src/main/scala/com/raquo/laminar/modifiers/Inserter.scala @@ -18,6 +18,7 @@ import com.raquo.laminar.nodes.ReactiveElement */ class Inserter[-El <: ReactiveElement.Base] ( initialContext: Option[InsertContext[El]] = None, + preferStrictMode: Boolean, insertFn: (InsertContext[El], Owner) => Subscription, ) extends Modifier[El] { @@ -26,10 +27,12 @@ class Inserter[-El <: ReactiveElement.Base] ( // - Currently this does not seem avoidable as we don't want to expose a `map` on DynSub // - That would allow you to create leaky resources without having a reference to the owner // - But maybe we require the user to provide proof of owner: dynSub.map(project)(owner) that must match DynSub - // @Note we want to remember this even after subscription is deactivated. + // #Note we want to remember this context even after subscription is deactivated. // Yes, we expect the subscription to re-activate with this initial state - // because it would match the state of the DOM upon reactivation. - val insertContext = initialContext.getOrElse(InsertContext.reserveSpotContext(element)) + // because it would match the state of the DOM upon reactivation + // (unless some of the managed child elements were externally removed from the DOM, + // which Laminar should be able to recover from). + val insertContext = initialContext.getOrElse(InsertContext.reserveSpotContext(element, strictMode = preferStrictMode)) ReactiveElement.bindSubscriptionUnsafe(element) { mountContext => insertFn(insertContext, mountContext.owner) @@ -47,6 +50,7 @@ class Inserter[-El <: ReactiveElement.Base] ( * The arrangement is admittedly a bit weird, but is required to build a smooth end user API. */ def withContext(context: InsertContext[El]): Inserter[El] = { - new Inserter[El](Some(context), insertFn) + // Note: preferStrictMode has no effect here, because initial context is defined. + new Inserter[El](Some(context), preferStrictMode = false, insertFn) } } diff --git a/src/main/scala/com/raquo/laminar/modifiers/Renderable.scala b/src/main/scala/com/raquo/laminar/modifiers/Renderable.scala new file mode 100644 index 00000000..84bf85ce --- /dev/null +++ b/src/main/scala/com/raquo/laminar/modifiers/Renderable.scala @@ -0,0 +1,42 @@ +package com.raquo.laminar.modifiers + +import com.raquo.laminar.nodes.TextNode + +import scala.annotation.implicitNotFound + +@implicitNotFound("Unable to convert ${A} to string / TextNode: no implicit instance of Renderable found for ${A}. If you want to render a non-primitive type as a string, define an implicit Renderable instance for it.") +trait Renderable[A] { + + def asString(value: A): String + + def asTextNode(value: A): TextNode = new TextNode(asString(value)) +} + +object Renderable { + + def apply[A](render: A => String): Renderable[A] = new Renderable[A] { + override def asString(value: A): String = render(value) + } + + // #Note[API] if you want Renderable[TextNode], please let me know why. + + implicit val stringRenderable: Renderable[String] = Renderable[String](identity) + + implicit val intRenderable: Renderable[Int] = Renderable[Int](_.toString) + + implicit val doubleRenderable: Renderable[Double] = Renderable[Double](_.toString) + + implicit val boolRenderable: Renderable[Boolean] = Renderable[Boolean](_.toString) + + // -- + + implicit lazy val charRenderable: Renderable[Char] = Renderable[Char](_.toString) + + implicit lazy val byteRenderable: Renderable[Byte] = Renderable[Byte](_.toString) + + implicit lazy val shortRenderable: Renderable[Short] = Renderable[Short](_.toString) + + implicit lazy val longRenderable: Renderable[Long] = Renderable[Long](_.toString) + + implicit lazy val floatRenderable: Renderable[Float] = Renderable[Float](_.toString) +} diff --git a/src/main/scala/com/raquo/laminar/modifiers/TextInserter.scala b/src/main/scala/com/raquo/laminar/modifiers/TextInserter.scala deleted file mode 100644 index 8a7cbc11..00000000 --- a/src/main/scala/com/raquo/laminar/modifiers/TextInserter.scala +++ /dev/null @@ -1,31 +0,0 @@ -package com.raquo.laminar.modifiers - -import com.raquo.airstream.core.Observable -import com.raquo.laminar.lifecycle.MountContext -import com.raquo.laminar.nodes.{ParentNode, ReactiveElement, TextNode} - -import scala.scalajs.js - -object TextInserter { - - def apply[T] ( - $text: MountContext[ReactiveElement.Base] => Observable[T], - render: T => TextNode - ): Inserter[ReactiveElement.Base] = new Inserter[ReactiveElement.Base]( - insertFn = (ctx, owner) => { - val mountContext = new MountContext[ReactiveElement.Base]( - thisNode = ctx.parentNode, - owner = owner - ) - var lastSeenText: js.UndefOr[T] = js.undefined - $text(mountContext).foreach { newText => - if (!lastSeenText.contains(newText)) { // #Note: auto-distinction - lastSeenText = newText - val newChildNode = render(newText) - ParentNode.replaceChild(parent = ctx.parentNode, oldChild = ctx.sentinelNode, newChild = newChildNode) - ctx.sentinelNode = newChildNode - } - }(owner) - } - ) -} diff --git a/src/main/scala/com/raquo/laminar/nodes/ParentNode.scala b/src/main/scala/com/raquo/laminar/nodes/ParentNode.scala index 9bda76ca..a5f1dad9 100644 --- a/src/main/scala/com/raquo/laminar/nodes/ParentNode.scala +++ b/src/main/scala/com/raquo/laminar/nodes/ParentNode.scala @@ -159,12 +159,11 @@ object ParentNode { newChild: ChildNode.Base ): Boolean = { var replaced = false - parent._maybeChildren.foreach { children => - - // 0. Check precondition required for consistency of our own Tree vs real DOM - if (oldChild != newChild) { - val indexOfChild = children.indexOf(oldChild) - if (indexOfChild != -1) { + // 0. Check precondition required for consistency of our own Tree vs real DOM + if (oldChild != newChild) { // #TODO[API] Can we move this check outside of replaceChild? Or will something break? Sometimes this check is extraneous. + parent._maybeChildren.foreach { children => + val indexOfOldChild = children.indexOf(oldChild) + if (indexOfOldChild != -1) { val newChildNextParent = Some(parent) oldChild.willSetParent(None) newChild.willSetParent(newChildNextParent) @@ -176,12 +175,22 @@ object ParentNode { oldChild = oldChild ) - // 2. Update this node - children.update(indexOfChild, newChild) - - // 3. Update children - oldChild.setParent(None) - newChild.setParent(newChildNextParent) + if (replaced) { + // 2A. Update new child's current parent node + newChild.maybeParent.foreach { childParent => + childParent._maybeChildren.foreach { children => + val ix = children.indexOf(newChild) + children.splice(ix, deleteCount = 1) + } + } + + // 2B. Update this node + children.update(indexOfOldChild, newChild) + + // 3. Update children + oldChild.setParent(None) + newChild.setParent(newChildNextParent) + } } } } diff --git a/src/main/scala/com/raquo/laminar/receivers/MaybeChildReceiver.scala b/src/main/scala/com/raquo/laminar/receivers/ChildOptionReceiver.scala similarity index 73% rename from src/main/scala/com/raquo/laminar/receivers/MaybeChildReceiver.scala rename to src/main/scala/com/raquo/laminar/receivers/ChildOptionReceiver.scala index 51a0dfda..c1abf840 100644 --- a/src/main/scala/com/raquo/laminar/receivers/MaybeChildReceiver.scala +++ b/src/main/scala/com/raquo/laminar/receivers/ChildOptionReceiver.scala @@ -5,12 +5,10 @@ import com.raquo.laminar.modifiers.ChildrenInserter.Child import com.raquo.laminar.modifiers.{ChildInserter, Inserter} import com.raquo.laminar.nodes.{CommentNode, ReactiveElement} -object MaybeChildReceiver { +object ChildOptionReceiver { def <--($maybeChildNode: Source[Option[Child]]): Inserter[ReactiveElement.Base] = { val emptyNode = new CommentNode("") - ChildInserter[ReactiveElement.Base]( - _ => $maybeChildNode.toObservable.map(_.getOrElse(emptyNode)) - ) + ChildInserter($maybeChildNode.toObservable.map(_.getOrElse(emptyNode))) } } diff --git a/src/main/scala/com/raquo/laminar/receivers/ChildReceiver.scala b/src/main/scala/com/raquo/laminar/receivers/ChildReceiver.scala index a483ce56..40dd9315 100644 --- a/src/main/scala/com/raquo/laminar/receivers/ChildReceiver.scala +++ b/src/main/scala/com/raquo/laminar/receivers/ChildReceiver.scala @@ -7,11 +7,11 @@ import com.raquo.laminar.nodes.ReactiveElement object ChildReceiver { - val maybe: MaybeChildReceiver.type = MaybeChildReceiver + val maybe: ChildOptionReceiver.type = ChildOptionReceiver - val text: TextChildReceiver.type = TextChildReceiver + val text: ChildTextReceiver.type = ChildTextReceiver def <--($node: Source[Child]): Inserter[ReactiveElement.Base] = { - ChildInserter[ReactiveElement.Base](_ => $node.toObservable) + ChildInserter($node.toObservable) } } diff --git a/src/main/scala/com/raquo/laminar/receivers/ChildTextReceiver.scala b/src/main/scala/com/raquo/laminar/receivers/ChildTextReceiver.scala new file mode 100644 index 00000000..471e2743 --- /dev/null +++ b/src/main/scala/com/raquo/laminar/receivers/ChildTextReceiver.scala @@ -0,0 +1,12 @@ +package com.raquo.laminar.receivers + +import com.raquo.airstream.core.Source +import com.raquo.laminar.modifiers.{ChildTextInserter, Inserter, Renderable} +import com.raquo.laminar.nodes.ReactiveElement + +object ChildTextReceiver { + + def <--[T]($node: Source[T])(implicit renderable: Renderable[T]): Inserter[ReactiveElement.Base] = { + ChildTextInserter($node.toObservable, renderable) + } +} diff --git a/src/main/scala/com/raquo/laminar/receivers/ChildrenCommandReceiver.scala b/src/main/scala/com/raquo/laminar/receivers/ChildrenCommandReceiver.scala index 67cb9166..72574d36 100644 --- a/src/main/scala/com/raquo/laminar/receivers/ChildrenCommandReceiver.scala +++ b/src/main/scala/com/raquo/laminar/receivers/ChildrenCommandReceiver.scala @@ -8,6 +8,6 @@ import com.raquo.laminar.nodes.ReactiveElement object ChildrenCommandReceiver { def <--($command: EventSource[ChildrenCommand]): Inserter[ReactiveElement.Base] = { - ChildrenCommandInserter[ReactiveElement.Base](_ => $command.toObservable) + ChildrenCommandInserter[ReactiveElement.Base]($command.toObservable) } } diff --git a/src/main/scala/com/raquo/laminar/receivers/ChildrenReceiver.scala b/src/main/scala/com/raquo/laminar/receivers/ChildrenReceiver.scala index 9ba7773a..614e23db 100644 --- a/src/main/scala/com/raquo/laminar/receivers/ChildrenReceiver.scala +++ b/src/main/scala/com/raquo/laminar/receivers/ChildrenReceiver.scala @@ -9,7 +9,11 @@ object ChildrenReceiver { val command: ChildrenCommandReceiver.type = ChildrenCommandReceiver + // Note: currently this method requires an observable of an **immutable** Seq, + // but if needed, I might be able to implement a version that works with + // arrays and mutable Seq-s too. + // Let me know if you have a compelling use case for this. def <--($children: Source[Children]): Inserter[ReactiveElement.Base] = { - ChildrenInserter[ReactiveElement.Base](_ => $children.toObservable) + ChildrenInserter($children.toObservable) } } diff --git a/src/main/scala/com/raquo/laminar/receivers/TextChildReceiver.scala b/src/main/scala/com/raquo/laminar/receivers/TextChildReceiver.scala deleted file mode 100644 index bfcce1e8..00000000 --- a/src/main/scala/com/raquo/laminar/receivers/TextChildReceiver.scala +++ /dev/null @@ -1,14 +0,0 @@ -package com.raquo.laminar.receivers - -import com.raquo.airstream.core.Source -import com.raquo.laminar.modifiers.{Inserter, TextInserter} -import com.raquo.laminar.nodes.{ReactiveElement, TextNode} - -object TextChildReceiver { - - // @TODO[Performance] We create new text nodes for every event. Should we update the node's text instead? - - def <--[T]($node: Source[T])(implicit render: T => TextNode): Inserter[ReactiveElement.Base] = { - TextInserter[T](_ => $node.toObservable, render) - } -} diff --git a/src/test/scala/com/raquo/laminar/ChildReceiverSpec.scala b/src/test/scala/com/raquo/laminar/ChildReceiverSpec.scala index eb344ce9..5ea05291 100644 --- a/src/test/scala/com/raquo/laminar/ChildReceiverSpec.scala +++ b/src/test/scala/com/raquo/laminar/ChildReceiverSpec.scala @@ -21,40 +21,40 @@ class ChildReceiverSpec extends UnitSpec { it("updates one child") { withClue("Stream:") { - test(makeObservable = identity) + test(makeObservable = identity, expectedInitialChild = None) } withClue("Signal:") { test( makeObservable = _.toSignal(span(text0)), - expectedInitialChild = span of text0 + expectedInitialChild = Some(span of text0) ) } def test( makeObservable: EventStream[ChildNode[dom.Element]] => Observable[ChildNode[dom.Element]], - expectedInitialChild: ExpectedNode = ExpectedNode.comment + expectedInitialChild: Option[ExpectedNode] ): Unit = { val childBus = new EventBus[ChildNode[dom.Element]] val $child = makeObservable(childBus.events) mount(div("Hello, ", child <-- $child)) - expectNode(div.of("Hello, ", expectedInitialChild)) + expectNode(div.of("Hello, ", sentinel, expectedInitialChild)) withClue("First event:") { childBus.writer.onNext(span(text1)) - expectNode(div.of("Hello, ", span of text1)) + expectNode(div.of("Hello, ", sentinel, span of text1)) } withClue("Second event, changing node type (span->div):") { childBus.writer.onNext(div(text2)) - expectNode(div.of("Hello, ", div of text2)) + expectNode(div.of("Hello, ", sentinel, div of text2)) } withClue("Third event:") { childBus.writer.onNext(div(text3)) - expectNode(div.of("Hello, ", div of text3)) + expectNode(div.of("Hello, ", sentinel, div of text3)) } unmount() @@ -65,7 +65,9 @@ class ChildReceiverSpec extends UnitSpec { withClue("Stream:") { test( makeFooObservable = identity, - makeBarObservable = identity + makeBarObservable = identity, + initialFooChild = None, + initialBarChild = None ) } @@ -73,82 +75,67 @@ class ChildReceiverSpec extends UnitSpec { test( makeFooObservable = _.toSignal(span(text0)), makeBarObservable = _.toSignal(span(text00)), - initialFooChild = span of text0, - initialBarChild = span of text00 + initialFooChild = Some(span of text0), + initialBarChild = Some(span of text00) ) } def test( makeFooObservable: EventStream[ChildNode[dom.Element]] => Observable[ChildNode[dom.Element]], makeBarObservable: EventStream[ChildNode[dom.Element]] => Observable[ChildNode[dom.Element]], - initialFooChild: ExpectedNode = ExpectedNode.comment, - initialBarChild: ExpectedNode = ExpectedNode.comment + initialFooChild: Option[ExpectedNode], + initialBarChild: Option[ExpectedNode] ): Unit = { val fooChildBus = new EventBus[ChildNode[dom.Element]] val barChildBus = new EventBus[ChildNode[dom.Element]] - mount(div(child <-- makeFooObservable(fooChildBus.events), child <-- makeBarObservable(barChildBus.events))) - expectNode(div.of(initialFooChild, initialBarChild)) + mount( + div( + child <-- makeFooObservable(fooChildBus.events), + child <-- makeBarObservable(barChildBus.events) + ) + ) + expectNode(div.of(sentinel, initialFooChild, sentinel, initialBarChild)) withClue("1. foo event:") { fooChildBus.writer.onNext(span(text1)) - expectNode(div.of(span of text1, initialBarChild)) + expectNode(div.of(sentinel, span of text1, sentinel, initialBarChild)) } withClue("2. bar event:") { barChildBus.writer.onNext(span(text4)) - expectNode(div.of(span of text1, span of text4)) + expectNode(div.of(sentinel, span of text1, sentinel, span of text4)) } withClue("3. another bar event:") { barChildBus.writer.onNext(span(text5)) - expectNode(div.of(span of text1, span of text5)) + expectNode(div.of(sentinel, span of text1, sentinel, span of text5)) } withClue("4. foo switch to div:") { fooChildBus.writer.onNext(div(text2)) - expectNode(div.of(div of text2, span of text5)) + expectNode(div.of(sentinel, div of text2, sentinel, span of text5)) } withClue("5. another foo event:") { fooChildBus.writer.onNext(div(text3)) - expectNode(div.of(div of text3, span of text5)) + expectNode(div.of(sentinel, div of text3, sentinel, span of text5)) } withClue("6. another bar event:") { barChildBus.writer.onNext(span(text6)) - expectNode(div.of(div of text3, span of text6)) + expectNode(div.of(sentinel, div of text3, sentinel, span of text6)) } withClue("7. yet another bar event:") { barChildBus.writer.onNext(span(text7)) - expectNode(div.of(div of text3, span of text7)) + expectNode(div.of(sentinel, div of text3, sentinel, span of text7)) } unmount() } } -// ignore("updates two children with the same stream") { -// -// } -// -// ignore("works when nested") { -// -// } -// -// ignore("works with an attr receiver on the same node") { -// -// } -// -// ignore("works with an attr receiver with nesting") { -// -// } -// -// ignore("works with an attr receiver on this node") { -// -// } - it("split[Option] - caching elements to change tag type") { var numCreateLinkCalls = 0 @@ -178,7 +165,10 @@ class ChildReceiverSpec extends UnitSpec { mount(el) expectNode( - div.of(L.a.of(href is "http://blog1.com/", "a blog")) + div.of( + sentinel, + L.a.of(href is "http://blog1.com/", "a blog") + ) ) numCreateLinkCalls shouldBe 1 @@ -189,7 +179,10 @@ class ChildReceiverSpec extends UnitSpec { numVar.writer.onNext(Some(2)) expectNode( - div.of(L.a.of(href is "http://blog2.com/", "a blog")) + div.of( + sentinel, + L.a.of(href is "http://blog2.com/", "a blog") + ) ) numCreateLinkCalls shouldBe 0 @@ -202,7 +195,10 @@ class ChildReceiverSpec extends UnitSpec { expectNode( el.ref, - div.of(L.a.of(href is "http://blog2.com/", "a blog")) + div.of( + sentinel, + L.a.of(href is "http://blog2.com/", "a blog") + ) ) numCreateLinkCalls shouldBe 0 @@ -212,7 +208,10 @@ class ChildReceiverSpec extends UnitSpec { mount(el) expectNode( - div.of(L.a.of(href is "http://blog2.com/", "a blog")) + div.of( + sentinel, + L.a.of(href is "http://blog2.com/", "a blog") + ) ) numCreateLinkCalls shouldBe 0 @@ -222,7 +221,10 @@ class ChildReceiverSpec extends UnitSpec { numVar.writer.onNext(Some(4)) expectNode( - div.of(L.a.of(href is "http://blog4.com/", "a blog")) + div.of( + sentinel, + L.a.of(href is "http://blog4.com/", "a blog") + ) ) // We don't clear memoized state when split signal is stopped anymore @@ -234,7 +236,10 @@ class ChildReceiverSpec extends UnitSpec { numVar.writer.onNext(None) expectNode( - div.of(i.of("no blog")) + div.of( + sentinel, + i.of("no blog") + ) ) // -- @@ -242,7 +247,10 @@ class ChildReceiverSpec extends UnitSpec { numVar.writer.onNext(None) expectNode( - div.of(i.of("no blog")) + div.of( + sentinel, + i.of("no blog") + ) ) numCreateLinkCalls shouldBe 0 @@ -252,9 +260,177 @@ class ChildReceiverSpec extends UnitSpec { numVar.writer.onNext(Some(5)) expectNode( - div.of(L.a.of(href is "http://blog5.com/", "a blog")) + div.of( + sentinel, + L.a.of(href is "http://blog5.com/", "a blog") + ) ) numCreateLinkCalls shouldBe 1 } + + it("can move child from one receiver to another") { + + val spanA = span("a") + val spanB = span("b") + val spanC = span("c") + val spanD = span("d") + val spanE = span("e") + + val bus1 = new EventBus[HtmlElement] + val bus2 = new EventBus[HtmlElement] + + val el = div( + child <-- bus1, + child <-- bus2, + ) + + mount(el) + + // -- + + expectNode( + div of( + sentinel, + sentinel + ) + ) + + // -- + + EventBus.emit( + bus1 -> spanA, + bus2 -> spanD + ) + + expectNode( + div of( + sentinel, + span of "a", + sentinel, + span of "d", + ) + ) + + // -- Steal D from inserter #2 to inserter #1 + + EventBus.emit( + bus1 -> spanD + ) + + expectNode( + div of( + sentinel, + span of "d", + sentinel + ) + ) + + // -- Request invalid state (same element in both places) + + EventBus.emit( + bus1 -> spanA, + bus2 -> spanA + ) + + expectNode( + div of( + sentinel, + sentinel, + span of "a" + ) + ) + + // -- Recover from invalid state + + EventBus.emit( + bus1 -> spanA, + bus2 -> spanB + ) + + expectNode( + div of( + sentinel, + span of "a", + sentinel, + span of "b", + ) + ) + + // -- Unmount and re-mount + + unmount() + + mount(el) + + expectNode( + div of( + sentinel, + span of "a", + sentinel, + span of "b", + ) + ) + + // -- + + EventBus.emit( + bus1 -> spanC, + bus2 -> spanD + ) + + expectNode( + div of( + sentinel, + span of "c", + sentinel, + span of "d" + ) + ) + + // -- + + EventBus.emit( + bus1 -> spanD, + bus2 -> spanE + ) + + expectNode( + div of( + sentinel, + span of "d", + sentinel, + span of "e" + ) + ) + + // -- + + bus1.emit(spanD) + bus2.emit(spanC) + + expectNode( + div of( + sentinel, + span of "d", + sentinel, + span of "c" + ) + ) + + // -- + + bus2.emit(spanD) + bus1.emit(spanC) + + expectNode( + div of( + sentinel, + span of "c", + sentinel, + span of "d" + ) + ) + } + } diff --git a/src/test/scala/com/raquo/laminar/ChildrenCommandReceiverSpec.scala b/src/test/scala/com/raquo/laminar/ChildrenCommandReceiverSpec.scala index af47e0f3..fb846f45 100644 --- a/src/test/scala/com/raquo/laminar/ChildrenCommandReceiverSpec.scala +++ b/src/test/scala/com/raquo/laminar/ChildrenCommandReceiverSpec.scala @@ -56,8 +56,7 @@ class ChildrenCommandReceiverSpec extends UnitSpec { withClue(clue) { val first: Rule = "Hello" val last: Rule = div of "World" - val sentinelNode: Rule = ExpectedNode.comment - val rules: Seq[Rule] = first +: sentinelNode +: childRules :+ last + val rules: Seq[Rule] = first +: (sentinel: Rule) +: childRules :+ last expectNode(div.of(rules: _*)) } diff --git a/src/test/scala/com/raquo/laminar/ChildrenReceiverSpec.scala b/src/test/scala/com/raquo/laminar/ChildrenReceiverSpec.scala index 96bbf58a..5210e14e 100644 --- a/src/test/scala/com/raquo/laminar/ChildrenReceiverSpec.scala +++ b/src/test/scala/com/raquo/laminar/ChildrenReceiverSpec.scala @@ -4,6 +4,7 @@ import com.raquo.domtestutils.matching.{ExpectedNode, Rule} import com.raquo.laminar.api.L._ import com.raquo.laminar.fixtures.AirstreamFixtures.Effect import com.raquo.laminar.utils.UnitSpec +import org.scalajs.dom import org.scalatest.BeforeAndAfter import scala.collection.mutable @@ -101,8 +102,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { withClue(clue) { val first: Rule = "Hello" val last: Rule = article of "World" - val sentinelNode: Rule = ExpectedNode.comment - val rules: Seq[Rule] = sentinelNode +: childRules + val rules: Seq[Rule] = (sentinel: Rule) +: childRules expectNode(main.of(rules: _*)) } @@ -151,7 +151,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment + sentinel )) effects shouldBe mutable.Buffer( @@ -166,7 +166,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment, + sentinel, div like ("ID: a", span like "a", "1"), div like ("ID: b", span like "b", "10") )) @@ -189,7 +189,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment, + sentinel, div like ("ID: a", span like "a", "1"), div like ("ID: b", span like "b", "10"), div like ("ID: c", span like "c", "100") @@ -335,7 +335,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment, + sentinel, div like ("ID: initial", span like "initial", "1"), )) @@ -354,7 +354,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment, + sentinel, div like ("ID: a", span like "a", "1"), div like ("ID: b", span like "b", "10") )) @@ -377,7 +377,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment, + sentinel, div like ("ID: a", span like "a", "1"), div like ("ID: b", span like "b", "10"), div like ("ID: c", span like "c", "100") @@ -521,7 +521,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment, + sentinel, div like ("ID: initial", span like "initial", "1"), )) @@ -540,7 +540,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment, + sentinel, div like ("ID: a", span like "a", "1"), div like ("ID: b", span like "b", "10") )) @@ -563,7 +563,7 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode(div like ( "Hello", - ExpectedNode.comment, + sentinel, div like ("ID: a", span like "a", "1"), div like ("ID: b", span like "b", "10"), div like ("ID: c", span like "c", "100") @@ -690,9 +690,9 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "--", - ExpectedNode.comment + sentinel ) ) @@ -705,12 +705,12 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "a", span of "b", span of "c", span of "--", - ExpectedNode.comment, + sentinel, span of "d", span of "e", span of "f" @@ -724,10 +724,10 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "a", span of "--", - ExpectedNode.comment, + sentinel, span of "d", ) ) @@ -741,11 +741,11 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "a", span of "d", span of "--", - ExpectedNode.comment, + sentinel, span of "e", ) ) @@ -759,10 +759,10 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "a", span of "--", - ExpectedNode.comment, + sentinel, span of "e", span of "d", ) @@ -777,11 +777,11 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "d", span of "a", span of "--", - ExpectedNode.comment, + sentinel, span of "e", ) @@ -796,11 +796,11 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "f", span of "c", span of "--", - ExpectedNode.comment, + sentinel, span of "e", span of "a", span of "d", @@ -817,13 +817,13 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "f", span of "a", span of "c", span of "d", span of "--", - ExpectedNode.comment, + sentinel, span of "e", ) ) @@ -837,15 +837,71 @@ class ChildrenReceiverSpec extends UnitSpec with BeforeAndAfter { expectNode( div of( - ExpectedNode.comment, + sentinel, span of "e", span of "--", - ExpectedNode.comment, + sentinel, span of "f", span of "a", span of "c", span of "d" ) ) + + // #TODO[Test]: also test for externally removing an element? + } + + it("unmount stream") { + + val bus = new EventBus[List[String]] + + val el = div( + children <-- bus.events.map(texts => texts.map(span(_))) + ) + + // -- + + mount(el) + + expectNode( + div of ( + sentinel + ) + ) + + // -- + + bus.emit(List("a", "b")) + + expectNode( + div of ( + sentinel, + span of "a", + span of "b" + ) + ) + + // -- + + unmount() + + expectNode( + el.ref, + div of( + sentinel, + span of "a", + span of "b" + ) + ) + + mount(el) + + expectNode( + div of( + sentinel, + span of "a", + span of "b" + ) + ) } } diff --git a/src/test/scala/com/raquo/laminar/LifecycleEventSpec.scala b/src/test/scala/com/raquo/laminar/LifecycleEventSpec.scala index 86d83d29..83f6ea8c 100644 --- a/src/test/scala/com/raquo/laminar/LifecycleEventSpec.scala +++ b/src/test/scala/com/raquo/laminar/LifecycleEventSpec.scala @@ -43,21 +43,21 @@ class LifecycleEventSpec extends UnitSpec { mount(section("Hello, ", child <-- $child)) events shouldBe mutable.Buffer() - expectNode(section.of("Hello, ", ExpectedNode.comment)) + expectNode(section.of("Hello, ", sentinel)) // -- textBus.writer.onNext("blah") events shouldBe mutable.Buffer(NodeDidMount) - expectNode(section.of("Hello, ", div.of("blah"))) + expectNode(section.of("Hello, ", sentinel, div.of("blah"))) // -- textBus.writer.onNext("world") events shouldBe mutable.Buffer(NodeDidMount, NodeWillUnmount, NodeDidMount) - expectNode(section.of("Hello, ", div.of("world"))) + expectNode(section.of("Hello, ", sentinel, div.of("world"))) } @@ -85,21 +85,21 @@ class LifecycleEventSpec extends UnitSpec { mount(section("Hello, ", child <-- $child)) events shouldBe mutable.Buffer() - expectNode(section.of("Hello, ", ExpectedNode.comment)) + expectNode(section.of("Hello, ", sentinel)) // -- textBus.writer.onNext("blah") events shouldBe mutable.Buffer(NodeDidMount) - expectNode(section.of("Hello, ", div.of( span.of("blah")))) + expectNode(section.of("Hello, ", sentinel, div.of(span.of("blah")))) // -- textBus.writer.onNext("world") events shouldBe mutable.Buffer(NodeDidMount, NodeWillUnmount, NodeDidMount) - expectNode(section.of("Hello, ", div.of( span.of("world")))) + expectNode(section.of("Hello, ", sentinel, div.of( span.of("world")))) } it("Changing parent on a node fires appropriate events") { diff --git a/src/test/scala/com/raquo/laminar/MountHooksSpec.scala b/src/test/scala/com/raquo/laminar/MountHooksSpec.scala index d22669c1..ab92aa55 100644 --- a/src/test/scala/com/raquo/laminar/MountHooksSpec.scala +++ b/src/test/scala/com/raquo/laminar/MountHooksSpec.scala @@ -1,6 +1,5 @@ package com.raquo.laminar -import com.raquo.domtestutils.matching.ExpectedNode import com.raquo.laminar.api.L._ import com.raquo.laminar.fixtures.TestableOwner import com.raquo.laminar.nodes.ReactiveElement @@ -175,7 +174,7 @@ class MountHooksSpec extends UnitSpec { ) withClue("before first mount:") { - expectNode(el.ref, div.of("Hello ", ExpectedNode.comment, ExpectedNode.comment, " world")) + expectNode(el.ref, div.of("Hello ", sentinel, sentinel, " world")) assert(numMountCalls == 0) // important part of API. We reserve the spot but don't call the hook yet. } @@ -184,7 +183,7 @@ class MountHooksSpec extends UnitSpec { mount(el) - expectNode(div.of("Hello ", span.of("Nikita"), ExpectedNode.comment, " world")) + expectNode(div.of("Hello ", sentinel, span.of("Nikita"), sentinel, " world")) assert(numMountCalls == 1) numMountCalls = 0 @@ -193,7 +192,7 @@ class MountHooksSpec extends UnitSpec { withClue("first mounted event:") { nameVar.writer.onNext("Yan") - expectNode(div.of("Hello ", span.of("Yan"), div.of("Yan"), " world")) + expectNode(div.of("Hello ", sentinel, span.of("Yan"), sentinel, div.of("Yan"), " world")) assert(numMountCalls == 0) } @@ -203,7 +202,7 @@ class MountHooksSpec extends UnitSpec { nameVar.writer.onNext("Igor") // Var updates, but element won't be populated until re-mounted - expectNode(el.ref, div.of("Hello ", span.of("Yan"), div.of("Yan"), " world"), "unmounted") + expectNode(el.ref, div.of("Hello ", sentinel, span.of("Yan"), sentinel, div.of("Yan"), " world"), "unmounted") assert(numMountCalls == 0) } @@ -216,7 +215,7 @@ class MountHooksSpec extends UnitSpec { nameVar.writer.onNext("Igor2") // this value will be resurrected when remounting, and DOM nodes will be fine owner.killSubscriptions() - expectNode(el.ref, div.of("Hello ", span.of("Yan"), div.of("Yan"), " world"), "unmounted") + expectNode(el.ref, div.of("Hello ", sentinel, span.of("Yan"), sentinel, div.of("Yan"), " world"), "unmounted") assert(numMountCalls == 0) assert(signal.now() == "Igor2") } @@ -225,7 +224,7 @@ class MountHooksSpec extends UnitSpec { mount(el) - expectNode(div.of("Hello ", span.of("Igor2"), div.of("Yan"), " world")) + expectNode(div.of("Hello ", sentinel, span.of("Igor2"), sentinel, div.of("Yan"), " world")) assert(numMountCalls == 1) numMountCalls = 0 @@ -234,7 +233,7 @@ class MountHooksSpec extends UnitSpec { withClue("first event after re-mounting:") { nameVar.writer.onNext("Elan") - expectNode(div.of("Hello ", span.of("Elan"), div.of("Elan"), " world")) + expectNode(div.of("Hello ", sentinel, span.of("Elan"), sentinel, div.of("Elan"), " world")) assert(numMountCalls == 0) } } @@ -286,7 +285,11 @@ class MountHooksSpec extends UnitSpec { assert(numBindCalls == 10) assert(numSignalCalls == 10) // due to new re-evaluation semantics of signals as of 0.15.0 - ReactiveElement.numDynamicSubscriptions(el) shouldBe 2 // one is onMountBind itself, the other is its payload + // 2 subs: + // - onMountBind, + // - title <-- signal + // Note that el's pilot sub is owned by el's parent, not el. + ReactiveElement.numDynamicSubscriptions(el) shouldBe 2 } it("onMountInsert child cancels previous dynamic subscriptions on unmount") { @@ -336,7 +339,14 @@ class MountHooksSpec extends UnitSpec { assert(numBindCalls == 10) assert(numSignalCalls == 10) // due to new re-evaluation semantics of signals as of 0.15.0 - ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 // one is onMountBind itself, the other is its payload, and one more is the child's pilot subscription + assert(el.ref.childNodes.length == 4) // Checks that onMountInsert properly discards of old items + + // 3 subs: + // - onMountBind, + // - child <-- signal + // - child's pilot subscription + // Note that el's pilot sub is owned by el's parent, not el. + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 } it("onMountInsert children cancels previous dynamic subscriptions on unmount") { @@ -386,7 +396,661 @@ class MountHooksSpec extends UnitSpec { assert(numBindCalls == 10) assert(numSignalCalls == 10) // due to new re-evaluation semantics of signals as of 0.15.0 - ReactiveElement.numDynamicSubscriptions(el) shouldBe 5 // one is onMountBind itself, the other is its payload, and three more for each child's pilot subscription + // 5 subs: + // - onMountBind, + // - children <-- signal + // - 3x subs, for each child's pilot subscription + // Note that el's pilot sub is owned by el's parent, not el. + ReactiveElement.numDynamicSubscriptions(el) shouldBe 5 + } + + it("onMountInsert switches between different types of inserters (signals)") { + + var numModCalls = 0 + var numBindCalls = 0 + var numChildrenSignalCalls = 0 + + // -- + + val xChildIx = Var(1) + + val xChildSignal = xChildIx.signal.map { n => + numChildrenSignalCalls += 1 + span("x" + n) + } + + // -- + + val yChildrenIx = Var(1) + + val yChildrenSignal = yChildrenIx.signal.map { n => + Range.inclusive(1, n).map(i => div(s"y$i/$n")) + } + + // -- + + val zChildrenIx = Var(1) + + val zChildrenSignal = zChildrenIx.signal.map { n => + Range.inclusive(1, n).map(i => p(s"z$i/$n")) + } + + // -- + + val xChildInserter = child <-- xChildSignal + + val yChildrenInserter = children <-- yChildrenSignal + + var dynamicInserter: Inserter[ReactiveElement.Base] = xChildInserter + + // -- + + val el = div( + "Hello ", + Modifier { _ => numModCalls += 1 }, + onMountInsert { _ => + numBindCalls += 1 + dynamicInserter + }, + children <-- zChildrenSignal, + " world" + ) + + mount("onMountInsert child <--:", el) + + // 5 subs: + // - child element pilot sub (from dynamicInserter) + // - child element pilot sub (from children <-- zChildrenSignal) + // - onMountInsert itself + // - dynamicInserter + // - children <-- zChildrenSignal + // (Note: el's own pilotSubscription is owned by el's parent, not el itself) + ReactiveElement.numDynamicSubscriptions(el) shouldBe 5 + + expectNode( + div of ( + "Hello ", + sentinel, + span of "x1", + sentinel, + p of "z1/1", + " world" + ) + ) + + // -- + + xChildIx.set(2) + + zChildrenIx.set(2) + + // Added one sub: + // - Child element pilot sub (now we have 2 children instead of 1 from children <-- zChildrenSignal) + ReactiveElement.numDynamicSubscriptions(el) shouldBe 6 + + expectNode( + div of( + "Hello ", + sentinel, + span of "x2", + sentinel, + p of "z1/2", + p of "z2/2", + " world" + ) + ) + + // -- + + unmount() + + xChildIx.set(3) + + zChildrenIx.set(3) + + mount("onMountInsert child <-- AGAIN:", el) + + // Added one sub: + // - Child element pilot sub (now we have 3 children instead of 1 from children <-- zChildrenSignal) + ReactiveElement.numDynamicSubscriptions(el) shouldBe 7 + + expectNode( + div of( + "Hello ", + sentinel, + span of "x3", + sentinel, + p of "z1/3", + p of "z2/3", + p of "z3/3", + " world" + ) + ) + + // -- + + unmount() + + dynamicInserter = yChildrenInserter + + mount("onMountInsert SWITCH to children <--:", el) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 7 + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/1", + sentinel, + p of "z1/3", + p of "z2/3", + p of "z3/3", + " world" + ) + ) + + // -- + + yChildrenIx.set(2) + + // Added one sub: + // - Child element pilot sub (now we have 3 children instead of 1 from dynamicInserter (child <-- yChildrenSignal)) + ReactiveElement.numDynamicSubscriptions(el) shouldBe 8 + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/2", + div of "y2/2", + sentinel, + p of "z1/3", + p of "z2/3", + p of "z3/3", + " world" + ) + ) + + // -- + + zChildrenIx.set(2) + + // Removed one sub: + // - Child element pilot sub (now we have 2 children instead of 3 from children <-- zChildrenSignal) + ReactiveElement.numDynamicSubscriptions(el) shouldBe 7 + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/2", + div of "y2/2", + sentinel, + p of "z1/2", + p of "z2/2", + " world" + ) + ) + + // -- + + unmount() + + dynamicInserter = xChildInserter + + mount("onMountInsert SWITCH BACK to child <--:", el) + + // Removed one sub: + // - Child element pilot sub (now we have 1 child instead of 2 from dynamicInserter) + ReactiveElement.numDynamicSubscriptions(el) shouldBe 6 + + expectNode( + div of( + "Hello ", + sentinel, + span of "x3", + sentinel, + p of "z1/2", + p of "z2/2", + " world" + ) + ) + + + assert(numModCalls == 1) + assert(numBindCalls == 4) + assert(numChildrenSignalCalls == 4) // due to new re-evaluation semantics of signals as of 0.15.0 + } + + it("onMountInsert switches between different types of inserters (streams)") { + var numModCalls = 0 + var numBindCalls = 0 + var numChildrenStreamCalls = 0 + + // -- + + val xChildIx = new EventBus[Int]() + + val xChildStream = xChildIx.events.map { n => + numChildrenStreamCalls += 1 + span("x" + n) + } + + // -- + + val yChildrenIx = new EventBus[Int] + + val yChildrenStream = yChildrenIx.events.map { n => + Range.inclusive(1, n).map(i => div(s"y$i/$n")) + } + + // -- + + val xChildInserter = child <-- xChildStream + + val yChildrenInserter = children <-- yChildrenStream + + var dynamicInserter: Inserter[ReactiveElement.Base] = xChildInserter + + // -- + + val el = div( + "Hello ", + Modifier { _ => numModCalls += 1 }, + onMountInsert { _ => + numBindCalls += 1 + dynamicInserter + }, + " world" + ) + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + " world" + ) + ) + + // 2 subs: + // - onMountInsert itself + // - dynamicInserter + // (Note: el's own pilotSubscription is owned by el's parent, not el itself) + ReactiveElement.numDynamicSubscriptions(el) shouldBe 2 + + // -- + + xChildIx.emit(1) + + expectNode( + div of( + "Hello ", + sentinel, + span of "x1", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + // -- + + unmount() + + dynamicInserter = yChildrenInserter + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + span of "x1", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + + // -- + + yChildrenIx.emit(1) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/1", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + // -- + + yChildrenIx.emit(2) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/2", + div of "y2/2", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 4 + + // -- unmount and re-mount: children <-- inserter elements are retained. + + unmount() + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/2", + div of "y2/2", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 4 + + // -- switching from `children <-- stream` with 2 elements to `child <-- stream` + + unmount() + + dynamicInserter = xChildInserter + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/2", + div of "y2/2", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 4 + + // -- + + xChildIx.emit(2) + + expectNode( + div of( + "Hello ", + sentinel, + span of "x2", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + // -- unmounting and re-mounting: child element is retained + + unmount() + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + span of "x2", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + // -- switching back to children <-- stream + + unmount() + + dynamicInserter = yChildrenInserter + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + span of "x2", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + // -- + + yChildrenIx.emit(1) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/1", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + // -- switch back to child <-- stream, but now children <-- inserter has only emitted a list of one child + + unmount() + + dynamicInserter = xChildInserter + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/1", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + // -- + + xChildIx.emit(3) + + expectNode( + div of( + "Hello ", + sentinel, + span of "x3", + " world" + ) + ) + + ReactiveElement.numDynamicSubscriptions(el) shouldBe 3 + + + assert(numModCalls == 1) + assert(numBindCalls == 7) + assert(numChildrenStreamCalls == 3) // due to new re-evaluation semantics of signals as of 0.15.0 + } + + it("onMountInsert switches between children and child.text (streams)") { + + val xChildIx = new EventBus[Int]() + + val xChildTextStream = xChildIx.events.map("x" + _) + + // -- + + val yChildrenIx = new EventBus[Int] + + val yChildrenStream = yChildrenIx.events.map { n => + Range.inclusive(1, n).map(i => div(s"y$i/$n")) + } + + // -- + + val xChildInserter = child.text <-- xChildTextStream + + val yChildrenInserter = children <-- yChildrenStream + + var dynamicInserter: Inserter[ReactiveElement.Base] = xChildInserter + + // -- + + val el = div( + "Hello ", + onMountInsert { _ => + dynamicInserter + }, + " world" + ) + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + " world" + ) + ) + + // -- + + unmount() + + dynamicInserter = yChildrenInserter + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + " world" + ) + ) + + // -- + + yChildrenIx.emit(1) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/1", + " world" + ) + ) + + // -- + + yChildrenIx.emit(2) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/2", + div of "y2/2", + " world" + ) + ) + + // -- + + unmount() + + dynamicInserter = xChildInserter + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/2", + div of "y2/2", + " world" + ) + ) + + // -- + + xChildIx.emit(1) + + expectNode( + div of( + "Hello ", + // Note: no extra sentinel node! For performance. + "x1", + " world" + ) + ) + + // -- + + xChildIx.emit(2) + + expectNode( + div of( + "Hello ", + // Note: no extra sentinel node! For performance. + "x2", + " world" + ) + ) + + // -- + + unmount() + + dynamicInserter = yChildrenInserter + + mount(el) + + expectNode( + div of( + "Hello ", + sentinel, // sentinel node already re-inserted by the children inserter + "x2", + " world" + ) + ) + + // -- + + yChildrenIx.emit(3) + + expectNode( + div of( + "Hello ", + sentinel, + div of "y1/3", + div of "y2/3", + div of "y3/3", + " world" + ) + ) } it("onFocusMount") { diff --git a/src/test/scala/com/raquo/laminar/ShadowDomSpec.scala b/src/test/scala/com/raquo/laminar/ShadowDomSpec.scala index 04dc5292..e362ebfc 100644 --- a/src/test/scala/com/raquo/laminar/ShadowDomSpec.scala +++ b/src/test/scala/com/raquo/laminar/ShadowDomSpec.scala @@ -58,7 +58,7 @@ class ShadowDomSpec extends UnitSpec { expectNode(app.ref, div.of( "Hello, ", - span.of(ExpectedNode.comment)) + span.of(sentinel)) ) // -- diff --git a/src/test/scala/com/raquo/laminar/SubscriptionLifecycleSpec.scala b/src/test/scala/com/raquo/laminar/SubscriptionLifecycleSpec.scala index 0583257b..ab294c52 100644 --- a/src/test/scala/com/raquo/laminar/SubscriptionLifecycleSpec.scala +++ b/src/test/scala/com/raquo/laminar/SubscriptionLifecycleSpec.scala @@ -232,11 +232,11 @@ class SubscriptionLifecycleSpec extends UnitSpec { makeElement = $child => span(child <-- $child, "Hello"), makeChildA = $testTitle => L.a(title <-- $testTitle), makeChildB = $testTitle => b(title <-- $testTitle), - emptyExpectedNode = span.of(ExpectedNode.comment, "Hello"), // @TODO[API] We should not need to reference EN.comment here of this (it's a sentinel node, and we should use implicit conversion) - emptyExpectedNodeA = span.of(L.a.of(title.isEmpty), "Hello"), - emptyExpectedNodeB = span.of(b.of(title.isEmpty), "Hello"), - makeExpectedNodeA = expectedTitle => span.of(L.a.of(title is expectedTitle), "Hello"), - makeExpectedNodeB = expectedTitle => span.of(b.of(title is expectedTitle), "Hello"), + emptyExpectedNode = span.of(sentinel, "Hello"), + emptyExpectedNodeA = span.of(sentinel, L.a.of(title.isEmpty), "Hello"), + emptyExpectedNodeB = span.of(sentinel, b.of(title.isEmpty), "Hello"), + makeExpectedNodeA = expectedTitle => span.of(sentinel, L.a.of(title is expectedTitle), "Hello"), + makeExpectedNodeB = expectedTitle => span.of(sentinel, b.of(title is expectedTitle), "Hello"), values = Seq("Title 1", "Title 2", "Title 3", "Title 4").map(randomString(_)) ).run()) } diff --git a/src/test/scala/com/raquo/laminar/SyntaxSpec.scala b/src/test/scala/com/raquo/laminar/SyntaxSpec.scala index 24c9448b..0d55ed59 100644 --- a/src/test/scala/com/raquo/laminar/SyntaxSpec.scala +++ b/src/test/scala/com/raquo/laminar/SyntaxSpec.scala @@ -15,6 +15,8 @@ import scala.scalajs.js class SyntaxSpec extends UnitSpec { + def noop[A](v: A): Unit = () + it("seqToModifier, seqToNode implicit conversion works for both strings and nodes") { val strings = List("a", "b") @@ -119,6 +121,22 @@ class SyntaxSpec extends UnitSpec { mount(el) } + it("onMountInsert with implicit renderable syntax") { + val el = div( + onMountInsert { _ => + "hello" + }, + onMountInsert { _ => + 123 + }, + "world" + ) + + mount(el) + + expectNode(div of ("hello", "123", "world")) + } + it("onMountBind with implicit setters syntax") { val el = div("Hello world") @@ -151,15 +169,14 @@ class SyntaxSpec extends UnitSpec { onMountBind(_ => onClick.mapTo(1) --> Observer[Int](num => num * 5)) ) - // #TODO[Test]: Not sure why we're printing stuff here. Two of those are polluting the test results a bit. el.amend( - onMountBind(_ => observable --> ((num: Int) => println(num * 5))), - onMountBind(_ => signal --> ((num: Int) => println(num * 5))), - onMountBind(_ => stream --> ((num: Int) => println(num * 5))) + onMountBind(_ => observable --> ((num: Int) => noop(num * 5))), + onMountBind(_ => signal --> ((num: Int) => noop(num * 5))), + onMountBind(_ => stream --> ((num: Int) => noop(num * 5))) ) el.amend( - onMountBind[Div](_.thisNode.events(onClick).map(_ => 1) --> (num => println(num * 5))) // @Note "Div" type param is required only in scala 2.12 + onMountBind[Div](_.thisNode.events(onClick).map(_ => 1) --> (num => noop(num * 5))) // @Note "Div" type param is required only in scala 2.12 ) el.amend( @@ -191,9 +208,9 @@ class SyntaxSpec extends UnitSpec { el.amend(onClick --> Observer[dom.MouseEvent](ev => ())) el.amend(onClick.mapTo(1) --> Observer[Int](num => num * 5)) - el.amend(observable --> ((num: Int) => println(num * 5))) - el.amend(signal --> ((num: Int) => println(num * 5))) - el.amend(stream --> ((num: Int) => println(num * 5))) + el.amend(observable --> ((num: Int) => noop(num * 5))) + el.amend(signal --> ((num: Int) => noop(num * 5))) + el.amend(stream --> ((num: Int) => noop(num * 5))) el.amendThis(_ => stream --> bus.writer) @@ -333,8 +350,8 @@ class SyntaxSpec extends UnitSpec { // No type param needed when in the element context div( - Modifier { el => println(el) }, - Setter { el => println(el) }, + Modifier { el => noop(el) }, + Setter { el => noop(el) }, Binder { el => null.asInstanceOf[DynamicSubscription] } ) diff --git a/src/test/scala/com/raquo/laminar/WeirdCasesSpec.scala b/src/test/scala/com/raquo/laminar/WeirdCasesSpec.scala index 092d7f1c..b0c7b995 100644 --- a/src/test/scala/com/raquo/laminar/WeirdCasesSpec.scala +++ b/src/test/scala/com/raquo/laminar/WeirdCasesSpec.scala @@ -5,6 +5,7 @@ import com.raquo.laminar.api.L._ import com.raquo.laminar.fixtures.TestableOwner import com.raquo.laminar.nodes.ReactiveElement import com.raquo.laminar.utils.UnitSpec +import org.scalajs.dom /** These tests verify Laminar's behaviour in weird cases. * @@ -36,16 +37,17 @@ class WeirdCasesSpec extends UnitSpec { ) ) - // @TODO[Test] we should not need to specify this sentinel comment node here. Gotta find a way to ignore those. - expectNode(div.of("hello", ExpectedNode.comment)) + expectNode(div.of("hello", sentinel)) bus.writer.onNext(1) expectNode( div.of( "hello", + sentinel, span.of( "num: 1", + sentinel, article of "innerNum: 1" ) ) @@ -56,8 +58,10 @@ class WeirdCasesSpec extends UnitSpec { expectNode( div.of( "hello", + sentinel, span.of( "num: 2", + sentinel, article of "innerNum: 2" ) ) @@ -81,8 +85,9 @@ class WeirdCasesSpec extends UnitSpec { ) ) - expectNode(div.of("hello", span.of( + expectNode(div.of("hello", sentinel, span.of( "num: 0", + sentinel, article of "innerNum: 0" ))) @@ -93,8 +98,10 @@ class WeirdCasesSpec extends UnitSpec { expectNode( div.of( "hello", + sentinel, span.of( "num: 1", + sentinel, article of "innerNum: 1" ) ) @@ -107,8 +114,10 @@ class WeirdCasesSpec extends UnitSpec { expectNode( div.of( "hello", + sentinel, span.of( "num: 2", + sentinel, article of "innerNum: 2" ) ) @@ -132,7 +141,7 @@ class WeirdCasesSpec extends UnitSpec { ) ) - expectNode(div.of("hello", ExpectedNode.comment)) + expectNode(div.of("hello", sentinel)) // -- @@ -141,8 +150,10 @@ class WeirdCasesSpec extends UnitSpec { expectNode( div.of( "hello", + sentinel, span.of( "num: 1", + sentinel, article of "innerNum: 1" ) ) @@ -155,8 +166,10 @@ class WeirdCasesSpec extends UnitSpec { expectNode( div.of( "hello", + sentinel, span.of( "num: 2", + sentinel, article of "innerNum: 2" ) ) @@ -191,7 +204,7 @@ class WeirdCasesSpec extends UnitSpec { withClue("before first mount:") { expectNode(el.ref, div.of( "Hello ", - ExpectedNode.comment, + sentinel, " world" )) } @@ -204,7 +217,7 @@ class WeirdCasesSpec extends UnitSpec { expectNode(div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Nikita"), " world" )) @@ -224,7 +237,7 @@ class WeirdCasesSpec extends UnitSpec { expectNode(div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Alpha"), span.of("Bravo"), span.of("Charlie"), @@ -256,7 +269,7 @@ class WeirdCasesSpec extends UnitSpec { expectNode(el.ref, div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Alpha"), span.of("Bravo"), span.of("Charlie"), @@ -282,7 +295,7 @@ class WeirdCasesSpec extends UnitSpec { expectNode(div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("John"), span.of("Alpha"), span.of("Delta"), @@ -310,7 +323,7 @@ class WeirdCasesSpec extends UnitSpec { expectNode(div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Tor"), span.of("Delta"), span.of("Bravo"), @@ -350,22 +363,22 @@ class WeirdCasesSpec extends UnitSpec { val signal = nameVar.signal // make a "copy" of each element, we can't put the same element twice into the dom - val stream = signal.changes.map(_.map(el => span(el.ref.textContent))) + val copyStream = signal.changes.map(_.map(el => span(el.ref.textContent))) val el = div( "Hello ", onMountInsert { _ => children <-- signal }, - onMountInsert { _ => children <-- stream}, + onMountInsert { _ => children <-- copyStream}, " world" ) withClue("before first mount:") { expectNode(el.ref, div.of( "Hello ", - ExpectedNode.comment, - ExpectedNode.comment, + sentinel, + sentinel, " world" )) } @@ -378,9 +391,9 @@ class WeirdCasesSpec extends UnitSpec { expectNode(div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Nikita"), - ExpectedNode.comment, + sentinel, " world" )) @@ -399,13 +412,13 @@ class WeirdCasesSpec extends UnitSpec { expectNode(div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Alpha"), span.of("Bravo"), span.of("Charlie"), span.of("Delta"), span.of("Eagle"), - ExpectedNode.comment, + sentinel, span.of("Alpha"), span.of("Bravo"), span.of("Charlie"), @@ -439,13 +452,13 @@ class WeirdCasesSpec extends UnitSpec { expectNode(el.ref, div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Alpha"), span.of("Bravo"), span.of("Charlie"), span.of("Delta"), span.of("Eagle"), - ExpectedNode.comment, + sentinel, span.of("Alpha"), span.of("Bravo"), span.of("Charlie"), @@ -470,19 +483,19 @@ class WeirdCasesSpec extends UnitSpec { // Important, we check that updateChildren logic can handle this scenario - observable getting ahead of the DOM val owner = new TestableOwner signal.foreach(_ => ())(owner) - stream.foreach(_ => ())(owner) + copyStream.foreach(_ => ())(owner) nameVar.writer.onNext(john :: alpha :: delta :: bravo :: tor :: Nil) owner.killSubscriptions() expectNode(el.ref, div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Alpha"), span.of("Bravo"), span.of("Charlie"), span.of("Delta"), span.of("Eagle"), - ExpectedNode.comment, + sentinel, span.of("Alpha"), span.of("Bravo"), span.of("Charlie"), @@ -508,13 +521,19 @@ class WeirdCasesSpec extends UnitSpec { expectNode(div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("John"), span.of("Alpha"), span.of("Delta"), span.of("Bravo"), span.of("Tor"), - ExpectedNode.comment, + sentinel, + // remember, these are from copyStream – so those are copies. + span.of("Alpha"), + span.of("Bravo"), + span.of("Charlie"), + span.of("Delta"), + span.of("Eagle"), " world") ) @@ -540,14 +559,14 @@ class WeirdCasesSpec extends UnitSpec { expectNode(div.of( "Hello ", - ExpectedNode.comment, + sentinel, span.of("Tor"), span.of("Delta"), span.of("Bravo"), span.of("Alpha"), span.of("Elan"), span.of("John"), - ExpectedNode.comment, + sentinel, span.of("Tor"), span.of("Delta"), span.of("Bravo"), diff --git a/src/test/scala/com/raquo/laminar/utils/LaminarSpec.scala b/src/test/scala/com/raquo/laminar/utils/LaminarSpec.scala index d2923848..110f9991 100644 --- a/src/test/scala/com/raquo/laminar/utils/LaminarSpec.scala +++ b/src/test/scala/com/raquo/laminar/utils/LaminarSpec.scala @@ -8,6 +8,7 @@ import com.raquo.laminar.defs.ComplexHtmlKeys.{CompositeHtmlAttr, CompositeProp} import com.raquo.laminar.keys.{HtmlAttr, Prop, StyleProp, SvgAttr} import com.raquo.laminar.nodes.{CommentNode, ReactiveElement, RootNode} import com.raquo.laminar.tags.Tag +import org.scalactic trait LaminarSpec extends MountOps @@ -21,9 +22,13 @@ trait LaminarSpec // realize isn't running because it's inside a None.foreach. var root: RootNode = null + def sentinel: ExpectedNode = ExpectedNode.comment + def mount( node: ReactiveElement.Base, clue: String = defaultMountedElementClue + )( + implicit pos: scalactic.source.Position ): Unit = { mountedElementClue = clue assertEmptyContainer("laminar.mount") @@ -33,11 +38,13 @@ trait LaminarSpec def mount( clue: String, node: ReactiveElement.Base + )( + implicit pos: scalactic.source.Position ): Unit = { - mount(node, clue) + mount(node, clue)(pos) } - override def unmount(clue: String = "unmount"): Unit = { + override def unmount(clue: String = "unmount")(implicit pos: scalactic.source.Position): Unit = { assertRootNodeMounted("unmount:" + clue) doAssert( root != null,