Skip to content

Commit

Permalink
API: onMount* methods now default to executing the mount callback whe…
Browse files Browse the repository at this point in the history
…n you add such modifiers to already mounted elements.

- Previously, the mount callbacks would not run in this case.
- You can get the previous behaviour by passing `ignoreAlreadyMounted = true`
- onMountFocus does not support `ignoreAlreadyMounted = true`, but you can write such a helper manually if needed.
- onMountUnmountCallbackWithState now provides the state as A instead of Option[A] by default, because the new default behaviour makes that safe.
- Otherwise, the UNmount callbacks are unaffected. onUnmountCallback still does not run until the element is actually being unmounted.

Fixes #149
  • Loading branch information
raquo committed Jan 16, 2025
1 parent 9c61be5 commit da5ff49
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 24 deletions.
113 changes: 90 additions & 23 deletions src/main/scala/com/raquo/laminar/api/MountHooks.scala
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import scala.scalajs.js

trait MountHooks {

/** Focus this element on mount */
/** Focus this element on mount.
*
* Starting with Laminar 18.x, the focus is set even if the element
* was already mounted by the time this modifier is added to it.
*/
val onMountFocus: Modifier[ReactiveHtmlElement.Base] = onMountCallback(_.thisNode.ref.focus())

/** Set a property / attribute / style on mount.
Expand All @@ -19,17 +23,31 @@ trait MountHooks {
* b) you truly need this to only happen on mount
*
* Example usage: `onMountSet(ctx => someAttr := someValue(ctx))`. See docs for details.
*
* @param ignoreAlreadyMounted If `false`, the `fn` mount callback will be called even if the
* element has already been mounted by the time this modifier is
* added to it.
* Starting with Laminar 18.x, `false` is the default.
*/
def onMountSet[El <: ReactiveElement.Base](fn: MountContext[El] => Setter[El]): Modifier[El] = {
onMountCallback(c => fn(c)(c.thisNode))
def onMountSet[El <: ReactiveElement.Base](
fn: MountContext[El] => Setter[El],
ignoreAlreadyMounted: Boolean = false
): Modifier[El] = {
onMountCallback(c => fn(c)(c.thisNode), ignoreAlreadyMounted)
}

/** Bind a subscription on mount
*
* Example usage: `onMountBind(ctx => someAttr <-- someObservable(ctx))`. See docs for details.
*
* @param ignoreAlreadyMounted If `false`, the `fn` mount callback will be called even if the
* element has already been mounted by the time this modifier is
* added to it.
* Starting with Laminar 18.x, `false` is the default.
*/
def onMountBind[El <: ReactiveElement.Base](
fn: MountContext[El] => Binder[El]
fn: MountContext[El] => Binder[El],
ignoreAlreadyMounted: Boolean = false
): Modifier[El] = {
var maybeSubscription: Option[DynamicSubscription] = None
onMountUnmountCallback(
Expand All @@ -40,7 +58,8 @@ trait MountHooks {
unmount = { _ =>
maybeSubscription.foreach(_.kill())
maybeSubscription = None
}
},
ignoreAlreadyMounted
)
}

Expand All @@ -50,8 +69,16 @@ trait MountHooks {
* Basically it will insert elements in the same position, where you'd expect, on every mount.
*
* Example usage: `onMountInsert(ctx => child <-- someObservable)`. See docs for details.
*
* @param ignoreAlreadyMounted If `false`, the `fn` mount callback will be called even if the
* element has already been mounted by the time this modifier is
* added to it.
* Starting with Laminar 18.x, `false` is the default.
*/
def onMountInsert[El <: ReactiveElement.Base](fn: MountContext[El] => Inserter): Modifier[El] = {
def onMountInsert[El <: ReactiveElement.Base](
fn: MountContext[El] => Inserter,
ignoreAlreadyMounted: Boolean = false
): Modifier[El] = {
Modifier[El] { element =>
var maybeSubscription: Option[DynamicSubscription] = None
// We start the context in loose mode for performance, because it's cheaper to go from there
Expand All @@ -77,7 +104,8 @@ trait MountHooks {
unmount = { _ =>
maybeSubscription.foreach(_.kill())
maybeSubscription = None
}
},
ignoreAlreadyMounted
)
)
}
Expand All @@ -86,18 +114,26 @@ trait MountHooks {
/** Execute a callback on mount. Good for integrating third party libraries.
*
* The callback runs on every mount, not just the first one.
* - Therefore, don't bind any subscriptions inside that you won't manually unbind on unmount.
* - If you fail to unbind manually, you will have N copies of them after mounting this element N times.
* - Use onMountBind or onMountInsert for that.
* - Therefore, don't bind any subscriptions inside that you won't manually unbind on unmount.
* - If you fail to unbind manually, you will have N copies of them after mounting this element N times.
* - Use onMountBind or onMountInsert for that.
*
* When the callback is called, the element is already mounted.
*
* If you apply this modifier to an element that is already mounted, the callback
* will not fire until and unless it is unmounted and mounted again.
*
* @param ignoreAlreadyMounted If `false`, the `fn` mount callback will be called even if the
* element has already been mounted by the time this modifier is
* added to it.
* Starting with Laminar 18.x, `false` is the default.
*/
def onMountCallback[El <: ReactiveElement.Base](fn: MountContext[El] => Unit): Modifier[El] = {
def onMountCallback[El <: ReactiveElement.Base](
fn: MountContext[El] => Unit,
ignoreAlreadyMounted: Boolean = false
): Modifier[El] = {
Modifier[El] { element =>
var ignoreNextActivation = ReactiveElement.isActive(element)
var ignoreNextActivation = ignoreAlreadyMounted && ReactiveElement.isActive(element)
ReactiveElement.bindCallback[El](element) { c =>
if (ignoreNextActivation) {
ignoreNextActivation = false
Expand All @@ -124,29 +160,60 @@ trait MountHooks {
}

/** Combines onMountCallback and onUnmountCallback for easier integration.
* - Note that the same caveats apply as for those individual methods.
* - See also: [[onMountUnmountCallbackWithState]]
* - Note that the same caveats apply as for those individual methods.
* - See also: [[onMountUnmountCallbackWithState]]
*
* @param ignoreAlreadyMounted If `false`, the `mount` callback will be called even if the
* element has already been mounted by the time this modifier
* is added to it.
* Starting with Laminar 18.x, `false` is the default.
*/
def onMountUnmountCallback[El <: ReactiveElement.Base](
mount: MountContext[El] => Unit,
unmount: El => Unit
unmount: El => Unit,
ignoreAlreadyMounted: Boolean = false
): Modifier[El] = {
onMountUnmountCallbackWithState[El, Unit](mount, (el, _) => unmount(el))
onMountUnmountCallbackWithState(
mount, (el, _: Any) => unmount(el), ignoreAlreadyMounted
)
}

/** Combines `onMountCallback` and `onUnmountCallback` for easier integration.
* - Note that the same caveats apply as for those individual methods.
* - This implementation defaults to `ignoreAlreadyMounted = false`.
*/
def onMountUnmountCallbackWithState[El <: ReactiveElement.Base, A](
mount: MountContext[El] => A,
unmount: (El, A) => Unit
): Modifier[El] = {
onMountUnmountCallbackWithState(
mount,
(el, maybeState: Option[A]) => unmount(el, maybeState.get),
ignoreAlreadyMounted = false
)
}

/** Combines onMountCallback and onUnmountCallback for easier integration.
* - Note that the same caveats apply as for those individual methods.
* - The mount callback returns state which will be provided to the unmount callback.
* - The unmount callback receives an Option of the state because it's possible that
* onMountUnmountCallbackWithState was called *after* the element was already mounted,
* in which case the mount callback defined here wouldn't have run.
* - Note that the same caveats apply as for those individual methods.
* - The mount callback returns state which will be provided to the unmount callback.
* - In this overloaded version, the unmount callback receives an Option of the state
* because it's possible that onMountUnmountCallbackWithState was called *after*
* the element was already mounted, in which case the mount callback defined here
* wouldn't have run.
* - To avoid this Option, don't provide any value for the `ignoreAlreadyMounted` argument.
*
* @param ignoreAlreadyMounted If `false`, the `mount` callback will be called even if the
* element has already been mounted by the time this modifier
* is added to it.
* Starting with Laminar 18.x, `false` is the default.
*/
def onMountUnmountCallbackWithState[El <: ReactiveElement.Base, A](
mount: MountContext[El] => A,
unmount: (El, Option[A]) => Unit
unmount: (El, Option[A]) => Unit,
ignoreAlreadyMounted: Boolean
): Modifier[El] = {
Modifier[El] { element =>
var ignoreNextActivation = ReactiveElement.isActive(element)
var ignoreNextActivation = ignoreAlreadyMounted && ReactiveElement.isActive(element)
var state: Option[A] = None
ReactiveElement.bindSubscriptionUnsafe[El](element) { c =>
if (ignoreNextActivation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.raquo.laminar.api.L._
import com.raquo.laminar.inserters.InserterHooks
import com.raquo.laminar.nodes.{ParentNode, ReactiveElement}
import com.raquo.laminar.utils.UnitSpec
import org.scalactic.source
import org.scalajs.dom

import scala.collection.mutable
Expand Down Expand Up @@ -191,14 +192,18 @@ class LifecycleEventSpec extends UnitSpec {

def subscribeToEvents(node: ReactiveElement[dom.html.Element]): Unit = {
node.amend(
onMountCallback(_ => lifecycleEvents = lifecycleEvents :+ ((node, NodeDidMount))),
onMountCallback(
_ => lifecycleEvents = lifecycleEvents :+ ((node, NodeDidMount)),
ignoreAlreadyMounted = true
),
onUnmountCallback(_ => lifecycleEvents = lifecycleEvents :+ ((node, NodeWillUnmount)))
)
}

def expectNewEvents(
clue: String,
expectedEvents: Seq[(ReactiveElement[dom.html.Element], LifecycleEvent)]
)(implicit pos: source.Position
): Unit = {
withClue(clue + ": ") {
lifecycleEvents shouldBe expectedEvents
Expand Down
56 changes: 56 additions & 0 deletions src/test/scala/com/raquo/laminar/tests/MountHooksSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,62 @@ class MountHooksSpec extends UnitSpec {
mount(el)
}

it("onMountCallback ignoreAlreadyMounted=true") {
var numMounts = 0
var numUnmounts = 0

val el = div(
"Hello",
onUnmountCallback(_ => numUnmounts += 1)
)

assert(numMounts == 0)
assert(numUnmounts == 0)

// --

mount(el)

assert(numMounts == 0)
assert(numUnmounts == 0)

// --

el.amend(
onMountCallback(_ => numMounts += 1, ignoreAlreadyMounted = true)
)

assert(numMounts == 0) // !!!
assert(numUnmounts == 0)

// --

unmount()

assert(numMounts == 0)
assert(numUnmounts == 1)

numUnmounts = 0

// --

mount(el)

assert(numMounts == 1)
assert(numUnmounts == 0)

numMounts = 0

// --

unmount()

assert(numMounts == 0)
assert(numUnmounts == 1)

numUnmounts = 0
}

it("Element lifecycle owners can not be used after unmount") {

var cleanedCounter = 0
Expand Down

0 comments on commit da5ff49

Please sign in to comment.