All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.raquo.laminar.inserters.InsertContext.scala Maven / Gradle / Ivy

The newest version!
package com.raquo.laminar.inserters

import com.raquo.ew.JsMap
import com.raquo.laminar
import com.raquo.laminar.DomApi
import com.raquo.laminar.nodes.{ChildNode, CommentNode, ParentNode, ReactiveElement}
import org.scalajs.dom

import scala.scalajs.js

// #TODO[Naming] This feels more like InserterState?
//  "Extra nodes" are more like "content nodes"

// @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 extraNodesMap       - Map of child nodes in addition to the sentinel node,
 *                              for more efficient search
 *                              Warning: can get out of sync with the real DOM!
 */
final class InsertContext(
  val parentNode: ReactiveElement.Base,
  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: immutable.Seq[ChildNode.Base],
  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("")
      DomApi.insertBefore(
        parent = parentNode.ref,
        newChild = newSentinelNode.ref,
        referenceChild = contentNode.ref
      )

      // Convert loose mode context values to strict mode context values
      sentinelNode = newSentinelNode
      extraNodeCount = 1
      // extraNodes = ChildrenSeq.fromJsVector(JsVector(contentNode))
      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(
    parentNode: ReactiveElement.Base,
    strictMode: Boolean,
    hooks: js.UndefOr[InserterHooks]
  ): InsertContext = {
    val sentinelNode = new CommentNode("")

    ParentNode.appendChild(parent = parentNode, child = sentinelNode, hooks)

    unsafeMakeReservedSpotContext(
      parentNode = parentNode,
      sentinelNode = sentinelNode,
      strictMode = strictMode
    )
  }

  /** Reserve the spot for when we actually insert real nodes later.
    *
    * Unsafe: you must make sure yourself that sentinelNode is already
    * a child of parentNode in the real DOM.
    *
    * This method is exposed to help third parties make hydration helpers.
    */
  def unsafeMakeReservedSpotContext(
    parentNode: ReactiveElement.Base,
    sentinelNode: ChildNode.Base,
    strictMode: Boolean
  ): InsertContext = {
    // #Warning[Fragile] - We avoid instantiating a JsMap in loose mode, for performance.
    //  The JsMap is initialized if/when needed, in forceSetStrictMode.
    new InsertContext(
      parentNode = parentNode,
      sentinelNode = sentinelNode,
      strictMode = strictMode,
      extraNodeCount = 0,
      // extraNodes = ChildrenSeq.empty,
      extraNodesMap = if (strictMode) new JsMap() else null
    )
  }

  private[laminar] def nodesToMap(nodes: laminar.Seq[ChildNode.Base]): JsMap[dom.Node, ChildNode.Base] = {
    val acc = new JsMap[dom.Node, ChildNode.Base]()
    nodes.foreach { node =>
      acc.set(node.ref, node)
    }
    acc
  }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy