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

com.raquo.laminar.nodes.ReactiveElement.scala Maven / Gradle / Ivy

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

import com.raquo.airstream.core.{EventStream, Observable, Observer, Sink, Transaction}
import com.raquo.airstream.eventbus.{EventBus, WriteBus}
import com.raquo.airstream.ownership.{DynamicOwner, DynamicSubscription, Subscription, TransferableSubscription}
import com.raquo.ew.JsArray
import com.raquo.laminar.keys.{CompositeKey, EventProcessor, Key}
import com.raquo.laminar.lifecycle.MountContext
import com.raquo.laminar.modifiers.{EventListener, Modifier}
import com.raquo.laminar.tags.Tag
import org.scalajs.dom

import scala.scalajs.js

trait ReactiveElement[+Ref <: dom.Element]
extends ChildNode[Ref]
with ParentNode[Ref] {

  // @Warning[Fragile] deactivate should not need an isActive guard.
  //  If Laminar starts to cause exceptions here, we need to find the root cause.
  /** This subscription activates and deactivates this element's subscriptions when mounting and unmounting this element.
    * In terms of memory management it's ok because a detached node will not have any active subscriptions here.
    */
  private val pilotSubscription: TransferableSubscription = new TransferableSubscription(
    activate = dynamicOwner.activate,
    deactivate = dynamicOwner.deactivate
  )

  // #TODO[API] Do we actually need this? What for?
  private[this] var maybeEventListeners: js.UndefOr[JsArray[EventListener.Base]] = js.undefined

  private[laminar] def foreachEventListener(f: EventListener.Base => Unit): Unit = {
    maybeEventListeners.foreach(_.forEach(f))
  }

  /** Note: Only call this after checking that this element doesn't already have this listener */
  private[laminar] def addEventListener(
    listener: EventListener.Base,
    unsafePrepend: Boolean
  ): Unit = {
    if (maybeEventListeners.isEmpty) {
      maybeEventListeners = JsArray[EventListener.Base](listener)
    } else if (unsafePrepend) {
      maybeEventListeners.get.unshift(listener)
    } else {
      maybeEventListeners.get.push(listener)
    }
  }

  /** Note: Only call this after checking that this element currently has this listener */
  private[laminar] def removeEventListener(index: Int): Unit = {
    maybeEventListeners.foreach { eventListeners =>
      eventListeners.splice(index, deleteCount = 1)
    }
  }

  /** @return -1 if not found */
  private[laminar] def indexOfEventListener(listener: EventListener.Base): Int = {
    maybeEventListeners.fold(ifEmpty = -1) { eventListeners =>
      var found: Boolean = false
      var ix = 0
      while (!found && ix < eventListeners.length) {
        if (eventListeners(ix) == listener) {
          found = true
        } else {
          ix += 1
        }
      }
      if (found) ix else -1
    }
  }

  /** This structure keeps track of reasons for including a given item for a given composite key.
    * For example, this remembers which modifiers have previously added which className.
    *
    * We need this to avoid interference in cases when two modifiers want to add the same
    * class name (e.g.) and one of those subsequently does not want to add that class name
    * anymore. Without this structure, the element would end up without that class name even
    * though one modifier still wants it added.
    *
    * Structure:
    *
    * Map(
    *   cls: List(
    *     "always" -> null,
    *     "present" -> null,
    *     "classes" -> null,
    *     "class1" -> modThatWantsClass1Set,
    *     "class1" -> anotherModThatWantsClass1Set,
    *     "class2" -> modThatWantsClass2Set
    *   ),
    *   rel: List( ... ),
    *   role: List( ... )
    * )
    *
    * Note that `mod` key can be null if the mod is not reactive, e.g. in the simple case of `cls` := "always"
    * This is to avoid keeping the mod in memory after it has served its purpose.
    *
    * Note that this structure can have redundant items (e.g. class names) in it, they are filtered out when writing to the DOM
    */
  private[this] var _compositeValues: Map[CompositeKey[_, this.type], List[(String, Modifier.Any)]] =
    Map.empty

  private[laminar] def compositeValueItems(
    prop: CompositeKey[_, this.type],
    reason: Modifier.Any
  ): List[String] = {
    _compositeValues
      .getOrElse(prop, Nil)
      .collect { case (item, r) if r == reason => item }
  }

  private[laminar] def updateCompositeValue(
    key: CompositeKey[_, this.type],
    reason: Modifier.Any,
    addItems: List[String],
    removeItems: List[String]
  ): Unit = {
    val keyItemsWithReason = _compositeValues.getOrElse(key, Nil)
    val itemHasAnotherReason = (item: String) => {
      // #Note null reason is shared among all static modifiers to avoid keeping a reference to them
      keyItemsWithReason.exists(t => t._1 == item && (t._2 != reason || reason == null))
    }

    val itemsToAdd = addItems.distinct
    val itemsToRemove = removeItems.filterNot(itemHasAnotherReason)
    val newItems = _compositeValues
      .getOrElse(key, Nil)
      .filterNot(t => itemsToRemove.contains(t._1)) ++ itemsToAdd.map((_, reason))

    val domValues = key.codec.decode(key.getRawDomValue(this))

    val nextDomValues = domValues.filterNot(itemsToRemove.contains) ++ itemsToAdd.filterNot(itemHasAnotherReason)

    // 1. Update Laminar's internal structure
    _compositeValues = _compositeValues.updated(key, newItems)

    // 2. Write desired state to the DOM
    // #Note this logic is compatible with third parties setting classes on Laminar elements
    //  using raw JS methods as long as they don't remove classes managed by Laminar or add
    //  classes that were also added by Laminar.
    key.setRawDomValue(this, key.codec.encode(nextDomValues))
  }

  val tag: Tag[ReactiveElement[Ref]]

  def eventListeners: List[EventListener.Base] = {
    maybeEventListeners.map(_.asScalaJs.toList).getOrElse(Nil)
  }

  /** Create and get a stream of events on this node */
  def events[Ev <: dom.Event, Out](
    prop: EventProcessor[Ev, Out]
  ): EventStream[Out] = {
    val eventBus = new EventBus[Out]
    val listener = new EventListener[Ev, Out](
      prop,
      eventBus.writer.onNext
    )
    listener(this)
    eventBus.events
  }

  // @TODO[Performance] Review scala.js generated code for single-element use case.
  //  - I would like to also have a `def amend(mod: Modifier[this.type])` method
  //  - but then SyntaxSpec / "amend on inlined element" test will fail. Check in Dotty later.
  //  - not a big deal but lame
  def amend(mods: Modifier[this.type]*): this.type = {
    Transaction.onStart.shared {
      mods.foreach(mod => mod(this))
      this
    }
  }

  def amendThis(makeMod: this.type => Modifier[this.type]): this.type = {
    val mod = makeMod(this)
    mod(this)
    this
  }

  private[laminar] def onBoundKeyUpdater(key: Key): Unit

  override private[nodes] def willSetParent(maybeNextParent: Option[ParentNode.Base]): Unit = {
    // println(s"> willSetParent of ${this.ref.tagName} to ${maybeNextParent.map(_.ref.tagName)}")

    // @Note this should cover ALL cases not covered by setParent
    if (isUnmounting(maybePrevParent = maybeParent, maybeNextParent = maybeNextParent)) {
      setPilotSubscriptionOwner(maybeNextParent)
    }
  }

  /** Don't call setParent directly – willSetParent will not be called. Use methods like `appendChild` defined on `ParentNode` instead. */
  override private[nodes] def setParent(maybeNextParent: Option[ParentNode.Base]): Unit = {
    // println(s"> setParent of ${this.ref.tagName} to ${maybeNextParent.map(_.ref.tagName)}")

    val maybePrevParent = maybeParent
    super.setParent(maybeNextParent)

    // @Note this should cover ALL cases not covered by willSetParent
    if (!isUnmounting(maybePrevParent = maybePrevParent, maybeNextParent = maybeNextParent)) {
      setPilotSubscriptionOwner(maybeNextParent)
    }
  }

  private[this] def isUnmounting(
    maybePrevParent: Option[ParentNode.Base],
    maybeNextParent: Option[ParentNode.Base]
  ): Boolean = {
    val isPrevParentActive = maybePrevParent.exists(_.dynamicOwner.isActive)
    val isNextParentActive = maybeNextParent.exists(_.dynamicOwner.isActive)
    isPrevParentActive && !isNextParentActive
  }

  private[this] def setPilotSubscriptionOwner(maybeNextParent: Option[ParentNode.Base]): Unit = {
    unsafeSetPilotSubscriptionOwner(maybeNextParent.map(_.dynamicOwner))
  }

  protected[this] def unsafeSetPilotSubscriptionOwner(maybeNextOwner: Option[DynamicOwner]): Unit = {
    // @Warning[Fragile] I had issues with clearOwner requiring a hasOwner check but that should not be necessary anymore.
    //  - If exceptions crop up caused by this, need to find the root cause before rushing to patch this here.
    maybeNextOwner.fold(pilotSubscription.clearOwner()) { nextOwner =>
      pilotSubscription.setOwner(nextOwner)
    }
  }
}

object ReactiveElement {

  type Base = ReactiveElement[dom.Element]

  @inline def bindFn[V](
    element: ReactiveElement.Base,
    observable: Observable[V]
  )(
    onNext: V => Unit
  ): DynamicSubscription = {
    DynamicSubscription.subscribeFn(element.dynamicOwner, observable, onNext)
  }

  @inline def bindObserver[V](
    element: ReactiveElement.Base,
    observable: Observable[V]
  )(
    observer: Observer[V]
  ): DynamicSubscription = {
    DynamicSubscription.subscribeObserver(element.dynamicOwner, observable, observer)
  }

  @inline def bindSink[V](
    element: ReactiveElement.Base,
    observable: Observable[V]
  )(
    sink: Sink[V]
  ): DynamicSubscription = {
    DynamicSubscription.subscribeSink(element.dynamicOwner, observable, sink)
  }

  @inline def bindBus[V](
    element: ReactiveElement.Base,
    eventStream: EventStream[V]
  )(
    writeBus: WriteBus[V]
  ): DynamicSubscription = {
    DynamicSubscription.subscribeBus(element.dynamicOwner, eventStream, writeBus)
  }

  /** #Note: Unsafe because you must make sure that the Subscription created by
    *  the `subscribe` callback is not killed externally. Otherwise, when the
    *  DynamicSubscription decides to kill it, the already-killed Subscription
    *  will throw an exception.
    */
  @inline def bindSubscriptionUnsafe[El <: ReactiveElement.Base](
    element: El
  )(
    subscribe: MountContext[El] => Subscription
  ): DynamicSubscription = {
    DynamicSubscription.unsafe(
      element.dynamicOwner,
      owner => subscribe(new MountContext[El](element, owner))
    )
  }

  @inline def bindCallback[El <: ReactiveElement.Base](
    element: El
  )(
    activate: MountContext[El] => Unit
  ): DynamicSubscription = {
    DynamicSubscription.subscribeCallback(
      element.dynamicOwner,
      owner => activate(new MountContext[El](element, owner))
    )
  }

  /** Bind subscription such that it will appear first in the list of dynamicOwner's subscriptions.
    * Be careful, use wisely. If you're using this for events, make sure that the listeners in the
    * DOM (and also the `maybeEventListeners` array) are in the same order.
    */
  private[laminar] def unsafeBindPrependSubscription[El <: ReactiveElement.Base](
    element: El
  )(
    subscribe: MountContext[El] => Subscription
  ): DynamicSubscription = {
    DynamicSubscription.unsafe(
      element.dynamicOwner,
      owner => subscribe(new MountContext[El](element, owner)),
      prepend = true
    )
  }

  // @TODO Maybe later. Wanted to make a seqToBinder.
  // @inline def bindCombinedSubscription[El <: ReactiveElement.Base](
  //   element: El
  // )(
  //   subscriptions: DynamicOwner => collection.Seq[DynamicSubscription]
  // ): DynamicSubscription = {
  //   DynamicSubscription.combine(element.dynamicOwner, subscriptions)
  // }

  def isActive(element: ReactiveElement.Base): Boolean = {
    element.dynamicOwner.isActive
  }

  /** You should not need this, we're just exposing this for testing. */
  def numDynamicSubscriptions(element: ReactiveElement.Base): Int = {
    element.dynamicOwner.numSubscriptions
  }

  /** Transform tag name obtained from the DOM to match the tag name
    * that we would have defined for it in HtmlTags / SvgTags.
    *
    * Basically this just means lower-casing HTML tag names, e.g.
    * transforming "DIV" returned from the DOM to "div", taking care
    * to avoid mangling custom element tag names.
    */
  def normalizeTagName(element: dom.Element): String = {
    val rawTagName = element.tagName
    val isCustomElement = rawTagName.contains("-")
    if (isCustomElement) {
      rawTagName // Don't touch tag names of custom elements
    } else {
      rawTagName.toLowerCase
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy