From da5ff49493578a27422d9e56a3ea543ed8687448 Mon Sep 17 00:00:00 2001 From: Nikita Gazarov Date: Wed, 15 Jan 2025 17:02:39 -0800 Subject: [PATCH] API: onMount* methods now default to executing the mount callback when 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 --- .../com/raquo/laminar/api/MountHooks.scala | 113 ++++++++++++++---- .../laminar/tests/LifecycleEventSpec.scala | 7 +- .../raquo/laminar/tests/MountHooksSpec.scala | 56 +++++++++ 3 files changed, 152 insertions(+), 24 deletions(-) diff --git a/src/main/scala/com/raquo/laminar/api/MountHooks.scala b/src/main/scala/com/raquo/laminar/api/MountHooks.scala index deb541f..f470077 100644 --- a/src/main/scala/com/raquo/laminar/api/MountHooks.scala +++ b/src/main/scala/com/raquo/laminar/api/MountHooks.scala @@ -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. @@ -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( @@ -40,7 +58,8 @@ trait MountHooks { unmount = { _ => maybeSubscription.foreach(_.kill()) maybeSubscription = None - } + }, + ignoreAlreadyMounted ) } @@ -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 @@ -77,7 +104,8 @@ trait MountHooks { unmount = { _ => maybeSubscription.foreach(_.kill()) maybeSubscription = None - } + }, + ignoreAlreadyMounted ) ) } @@ -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 @@ -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) { diff --git a/src/test/scala/com/raquo/laminar/tests/LifecycleEventSpec.scala b/src/test/scala/com/raquo/laminar/tests/LifecycleEventSpec.scala index 6d66653..5b18c49 100644 --- a/src/test/scala/com/raquo/laminar/tests/LifecycleEventSpec.scala +++ b/src/test/scala/com/raquo/laminar/tests/LifecycleEventSpec.scala @@ -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 @@ -191,7 +192,10 @@ 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))) ) } @@ -199,6 +203,7 @@ class LifecycleEventSpec extends UnitSpec { def expectNewEvents( clue: String, expectedEvents: Seq[(ReactiveElement[dom.html.Element], LifecycleEvent)] + )(implicit pos: source.Position ): Unit = { withClue(clue + ": ") { lifecycleEvents shouldBe expectedEvents diff --git a/src/test/scala/com/raquo/laminar/tests/MountHooksSpec.scala b/src/test/scala/com/raquo/laminar/tests/MountHooksSpec.scala index 6713118..d56004c 100644 --- a/src/test/scala/com/raquo/laminar/tests/MountHooksSpec.scala +++ b/src/test/scala/com/raquo/laminar/tests/MountHooksSpec.scala @@ -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