diff --git a/.gitignore b/.gitignore index 6aa54fba..7bcd2bef 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ smartgcal # Node modules node_modules/ package-lock.json + +# Nix +.direnv/ \ No newline at end of file diff --git a/README.md b/README.md index 04464afd..d161d6e1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ It provides several `zoom` functions for drilling down its properties. It also a `ViewOpt[A]` and `ViewList[A]` are variants that hold a value known to be an `Option[A]` or `List[A]` respectively. They are returned when `zoom`ing using `Optional`, `Prism` or `Traversal`. +## ViewThrottler[A] + +A `ViewThrowttler[A]` creates two `View[A]` from a given `View[A]`: + - A `throttledView`, which can be paused. While paused, it accumulates updates and applies them all at once upon timeout. The callback is called only once and only to the function provided in the last update during the pause. If unpaused, it acts as a normal `ViewF`. + - A `throttlerView`, which will pause the `throttledView` whenever it is modified. + + This is particularly useful for values that can be both updated from a UI and from a server. The `throttlerView` should be used in the UI, while the `throttledView` should be used for the server updates. This way, the server updates will pause whenever the user changes a value. If the server sends updates for every changed value, the throttling will avoid the UI from glitching between old and new values when the UI is updated quickly. + ## Reuse[A] A `Reuse[A]` wraps a value of type `A` and a hidden value of another type `B` such that there is a implicit `Reusability[B]`. @@ -157,6 +165,10 @@ Similar to `useStateView` but returns a `Reuse[View[A]]`. The resulting `View` i useStateViewWithReuseBy[A: ClassTag: Reusability](initialValue: Ctx => A): Reuse[View[A]] ``` +### useThrottlingStateView + +Same as `useStateView` but provides 2 `View`s over the same value, See [`ViewThrottler[A]`](#viewthrottlera). + ### useSerialState Creates component state that is reused as long as it's not updated. diff --git a/build.sbt b/build.sbt index a6a41e47..cd4f6137 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,7 @@ Global / onChangedBuildSource := ReloadOnSourceChanges -ThisBuild / crossScalaVersions := List("3.4.3") -ThisBuild / tlBaseVersion := "0.42" +ThisBuild / crossScalaVersions := List("3.5.0") +ThisBuild / tlBaseVersion := "0.43" ThisBuild / tlCiReleaseBranches := Seq("master") diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseShadowRef.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseShadowRef.scala index 18cb0a6a..8edd426a 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/UseShadowRef.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseShadowRef.scala @@ -6,7 +6,6 @@ package crystal.react.hooks import japgolly.scalajs.react.* import japgolly.scalajs.react.hooks.CustomHook import japgolly.scalajs.react.hooks.Hooks -import japgolly.scalajs.react.util.DefaultEffects.Sync as DefaultS object UseShadowRef { def hook[A]: CustomHook[A, NonEmptyRef.Get[A]] = diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/UseThrottlingStateView.scala b/modules/core/js/src/main/scala/crystal/react/hooks/UseThrottlingStateView.scala new file mode 100644 index 00000000..cfe898e0 --- /dev/null +++ b/modules/core/js/src/main/scala/crystal/react/hooks/UseThrottlingStateView.scala @@ -0,0 +1,71 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package crystal.react.hooks + +import crystal.Pot +import crystal.react.ThrottlingView +import crystal.react.ViewThrottler +import japgolly.scalajs.react.* +import japgolly.scalajs.react.hooks.CustomHook + +import scala.concurrent.duration.FiniteDuration + +object UseThrottlingStateView { + def hook[A]: CustomHook[(A, FiniteDuration), Pot[ThrottlingView[A]]] = + CustomHook[(A, FiniteDuration)] + .useStateViewBy(props => props._1) + .useEffectResultOnMountBy((props, _) => ViewThrottler[A](props._2)) + .buildReturning: (_, view, throttler) => + throttler.map(_.throttle(view)) + + object HooksApiExt { + sealed class Primary[Ctx, Step <: HooksApi.AbstractStep](api: HooksApi.Primary[Ctx, Step]) { + + /** Creates component state as a `ThrottlingView`. See `ViewThrottler[A]`. */ + final def useThrottlingStateView[A](initialValue: => A, timeout: FiniteDuration)(using + step: Step + ): step.Next[Pot[ThrottlingView[A]]] = + useThrottlingStateViewBy(_ => (initialValue, timeout)) + + /** Creates component state as a `ThrottlingView`. See `ViewThrottler[A]`. */ + final def useThrottlingStateViewBy[A](props: Ctx => (A, FiniteDuration))(using + step: Step + ): step.Next[Pot[ThrottlingView[A]]] = + api.customBy { ctx => + val hookInstance = hook[A] + hookInstance(props(ctx)) + } + } + + final class Secondary[Ctx, CtxFn[_], Step <: HooksApi.SubsequentStep[Ctx, CtxFn]]( + api: HooksApi.Secondary[Ctx, CtxFn, Step] + ) extends Primary[Ctx, Step](api) { + + /** Creates component state as a `ThrottlingView`. See `ViewThrottler[A]`. */ + def useThrottlingStateViewBy[A](props: CtxFn[(A, FiniteDuration)])(using + step: Step + ): step.Next[Pot[ThrottlingView[A]]] = + useThrottlingStateViewBy(step.squash(props)(_)) + } + } + + protected trait HooksApiExt { + import HooksApiExt.* + + implicit def hooksExtThrottlingStateView1[Ctx, Step <: HooksApi.AbstractStep]( + api: HooksApi.Primary[Ctx, Step] + ): Primary[Ctx, Step] = + new Primary(api) + + implicit def hooksExtThrottlingStateView2[Ctx, CtxFn[_], Step <: HooksApi.SubsequentStep[ + Ctx, + CtxFn + ]]( + api: HooksApi.Secondary[Ctx, CtxFn, Step] + ): Secondary[Ctx, CtxFn, Step] = + new Secondary(api) + } + + object syntax extends HooksApiExt +} diff --git a/modules/core/js/src/main/scala/crystal/react/hooks/package.scala b/modules/core/js/src/main/scala/crystal/react/hooks/package.scala index 03b78a4e..c59d1694 100644 --- a/modules/core/js/src/main/scala/crystal/react/hooks/package.scala +++ b/modules/core/js/src/main/scala/crystal/react/hooks/package.scala @@ -7,14 +7,13 @@ import cats.effect.Fiber import cats.effect.Resource import crystal.Pot import crystal.react.reuse.* -import japgolly.scalajs.react.* import japgolly.scalajs.react.util.DefaultEffects.Async as DefaultA export UseSingleEffect.syntax.*, UseSerialState.syntax.*, UseStateCallback.syntax.*, UseStateView.syntax.*, UseStateViewWithReuse.syntax.*, UseSerialStateView.syntax.*, UseAsyncEffect.syntax.*, UseEffectResult.syntax.*, UseResource.syntax.*, UseStreamResource.syntax.*, UseEffectWhenDepsReady.syntax.*, UseEffectStreamResource.syntax.*, - UseShadowRef.syntax.* + UseShadowRef.syntax.*, UseThrottlingStateView.syntax.* type UnitFiber[F[_]] = Fiber[F, Throwable, Unit] diff --git a/modules/core/js/src/main/scala/crystal/react/package.scala b/modules/core/js/src/main/scala/crystal/react/package.scala index c9fe4812..93ef17fe 100644 --- a/modules/core/js/src/main/scala/crystal/react/package.scala +++ b/modules/core/js/src/main/scala/crystal/react/package.scala @@ -14,6 +14,7 @@ import japgolly.scalajs.react.util.DefaultEffects.Sync as DefaultS import japgolly.scalajs.react.util.Effect.UnsafeSync import japgolly.scalajs.react.vdom.VdomNode +import scala.concurrent.duration.FiniteDuration import scala.reflect.ClassTag type SetState[F[_], A] = A => F[Unit] @@ -42,6 +43,9 @@ type ReuseView[A] = Reuse[View[A]] type ReuseViewOpt[A] = Reuse[ViewOpt[A]] type ReuseViewList[A] = Reuse[ViewList[A]] +type ViewThrottler[A] = ViewThrottlerF[DefaultS, DefaultA, A] +type ThrottlingView[A] = ThrottlingViewF[DefaultS, DefaultA, A] + export crystal.react.syntax.all.*, crystal.react.syntax.all.given val syncToAsync: DefaultS ~> DefaultA = new FunctionK[DefaultS, DefaultA] { self => @@ -86,3 +90,6 @@ class FromStateReuseView { $.state >>= (oldState => $.modState(f, $.state.flatMap(newState => cb(oldState, newState)))) ) } + +def ViewThrottler[A](timeout: FiniteDuration): DefaultA[ViewThrottler[A]] = + ViewThrottlerF[DefaultS, DefaultA, A](timeout, syncToAsync, _.runAsyncAndForget) diff --git a/modules/core/shared/src/main/scala/crystal/ThrottlingViewF.scala b/modules/core/shared/src/main/scala/crystal/ThrottlingViewF.scala new file mode 100644 index 00000000..e0f60e1b --- /dev/null +++ b/modules/core/shared/src/main/scala/crystal/ThrottlingViewF.scala @@ -0,0 +1,12 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package crystal + +/** + * Result from `ViewThrottlerF`. + */ +case class ThrottlingViewF[F[_], G[_], A]( + throttlerView: ViewF[F, A], + throttledView: ViewF[G, A] +) diff --git a/modules/core/shared/src/main/scala/crystal/ViewThrottlerF.scala b/modules/core/shared/src/main/scala/crystal/ViewThrottlerF.scala new file mode 100644 index 00000000..7f233f69 --- /dev/null +++ b/modules/core/shared/src/main/scala/crystal/ViewThrottlerF.scala @@ -0,0 +1,82 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package crystal + +import cats.effect.Ref +import cats.effect.Temporal +import cats.effect.syntax.all.* +import cats.syntax.all.* +import cats.~> + +import scala.concurrent.duration.FiniteDuration + +/** + * Given a `ViewF`, creates two `ViewF`s over the same value: + * - A `throttledView`, which can be paused. While paused, it accumulates updates and applies them + * all at once upon timeout. The callback is called only once and only to the function provided + * in the last update during the pause. If unpaused, it acts as a normal `ViewF`. + * - A `throttlerView`, which will pause the `throttledView` whenever it is modified. + * This is particularly useful for values that can be both updated from a UI and from a server. The + * `throttlerView` should be used in the UI, while the `throttledView` should be used for the server + * updates. This way, the server updates will pause whenever the user changes a value. If the server + * sends updates for every changed value, the throttling will avoid the UI from glitching between + * old and new values when the UI is updated quickly. + * @typeparam + * F The sync effect. Your `ViewF` should be in this effect. + * @typeparam + * G The async effect. This will be used for concurrency. + */ +final class ViewThrottlerF[F[_], G[_], A] private ( + waitUntil: Ref[G, FiniteDuration], + nextUpdate: Ref[G, Option[ViewThrottlerF.ModCBType[G, A]]], + timeout: FiniteDuration, + syncToAsync: F ~> G, + dispatchAsync: G[Unit] => F[Unit] +)(using G: Temporal[G]) { + + private def throttle: F[Unit] = + dispatchAsync: + G.monotonic.flatMap(now => waitUntil.set(now + timeout)) + + private def throttlerView(view: ViewF[F, A]): ViewF[F, A] = + view.withOnMod(_ => throttle) + + private def attemptSet(modCB: (A => A, (A, A) => G[Unit]) => G[Unit]): G[Unit] = + (waitUntil.get, G.monotonic).flatMapN: (waitUntil, now) => + if (waitUntil > now) + (G.sleep(waitUntil - now) >> attemptSet(modCB)).start.void + else + nextUpdate + .flatModify: accumModCb => + (none, accumModCb.map((mod, cb) => modCB(mod, cb)).getOrElse(G.unit)) + .flatTap(_ => G.unit) + + private def throttledView(view: ViewF[F, A]): ViewF[G, A] = + new ViewF[G, A]( + get = view.get, + modCB = (mod, cb) => + nextUpdate.update: x => + x match + case None => (mod, cb).some + case Some(oldMod, _) => (oldMod.andThen(mod), cb).some // We only keep last CB + >> + attemptSet: (newMod, newCB) => + syncToAsync(view.modCB(newMod, (oldA, newA) => dispatchAsync(newCB(oldA, newA)))) + ) + + def throttle(view: ViewF[F, A]): ThrottlingViewF[F, G, A] = + ThrottlingViewF(throttlerView(view), throttledView(view)) +} + +object ViewThrottlerF { + private type ModCBType[F[_], A] = (A => A, (A, A) => F[Unit]) + + def apply[F[_], G[_], A]( + timeout: FiniteDuration, + syncToAsync: F ~> G, + dispatchAsync: G[Unit] => F[Unit] + )(using G: Temporal[G]): G[ViewThrottlerF[F, G, A]] = + (G.monotonic.flatMap(G.ref(_)), G.ref(none[ModCBType[G, A]])) + .mapN(new ViewThrottlerF(_, _, timeout, syncToAsync, dispatchAsync)) +} diff --git a/modules/core/shared/src/main/scala/crystal/package.scala b/modules/core/shared/src/main/scala/crystal/package.scala index 07ac3e7c..d079aa0f 100644 --- a/modules/core/shared/src/main/scala/crystal/package.scala +++ b/modules/core/shared/src/main/scala/crystal/package.scala @@ -5,10 +5,8 @@ package crystal import cats.Applicative import cats.Eq -import cats.FlatMap import cats.InvariantSemigroupal import cats.Monad -import cats.effect.Ref import cats.syntax.all.* export syntax.* @@ -17,15 +15,6 @@ extension [F[_]: Monad](f: F[Unit]) def when(cond: F[Boolean]): F[Unit] = cond.flatMap(f.whenA) -def refModCB[F[_]: FlatMap, A](ref: Ref[F, A]): (A => A, (A, A) => F[Unit]) => F[Unit] = - (f, cb) => - ref - .modify[(A, A)]: previous => - val current = f(previous) - (current, (previous, current)) - .flatMap: (previous, current) => - cb(previous, current) - extension [F[_]: Applicative](opt: Option[F[Unit]]) def orUnit: F[Unit] = opt.getOrElse(Applicative[F].unit) diff --git a/modules/tests/shared/src/test/scala/crystal/ViewFLawsSpec.scala b/modules/tests/shared/src/test/scala/crystal/ViewFLawsSpec.scala index d369af97..f17ba4de 100644 --- a/modules/tests/shared/src/test/scala/crystal/ViewFLawsSpec.scala +++ b/modules/tests/shared/src/test/scala/crystal/ViewFLawsSpec.scala @@ -3,7 +3,6 @@ package crystal -import cats.Eq import cats.Id import cats.Invariant import cats.laws.discipline.InvariantSemigroupalTests diff --git a/modules/tests/shared/src/test/scala/crystal/ViewThrottlerFSpec.scala b/modules/tests/shared/src/test/scala/crystal/ViewThrottlerFSpec.scala new file mode 100644 index 00000000..fc64f950 --- /dev/null +++ b/modules/tests/shared/src/test/scala/crystal/ViewThrottlerFSpec.scala @@ -0,0 +1,85 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package crystal + +import cats.arrow.FunctionK +import cats.effect.IO +import cats.effect.testkit.TestControl +import fs2.Stream +import munit.CatsEffectSuite + +import scala.concurrent.duration.* + +class ViewThrottlerFSpec extends munit.CatsEffectSuite: + def server[A](values: (A => A, FiniteDuration)*): Stream[IO, A => A] = + Stream + .emits(values) + .evalMap: (mod, t) => + IO.sleep(t).as(mod) + .prefetchN(Int.MaxValue) + + def plus(a: Int): Int => Int = _ + a + + def buildViews( + timeout: FiniteDuration + ): IO[(ViewF[IO, Int], ViewF[IO, Int], IO[List[(Int, FiniteDuration)]])] = + for + view <- AccumulatingViewF.of[IO, Int](0) + throttler <- ViewThrottlerF[IO, IO, Int](timeout, FunctionK.id[IO], identity) + yield + val throttlingView = throttler.throttle(view) + (throttlingView.throttlerView, throttlingView.throttledView, view.accumulated.map(_.toList)) + + test("server modifies immediately if unthrottled"): + TestControl + .executeEmbed: + for + (_, throttledView, getAccum) <- buildViews(10.millis) + _ <- + server(plus(1) -> 1.millis, plus(1) -> 1.millis, plus(1) -> 1.millis) + .evalMap(throttledView.mod) + .compile + .drain + accum <- getAccum + yield accum.toList + .assertEquals(List(0 -> 0.millis, 1 -> 1.millis, 2 -> 2.millis, 3 -> 3.millis)) + + test("respects throttling") { + TestControl + .executeEmbed: + for + (throttlerView, throttledView, getAccum) <- buildViews(100.millis) + _ <- + server(plus(1) -> 10.millis, plus(1) -> 20.millis, plus(1) -> 200.millis) + .evalMap(throttledView.mod) + .compile + .drain + .background + .use: result => + IO.sleep(1.millis) >> throttlerView.mod(plus(1)) >> result.flatMap(_.embedError) + accum <- getAccum + yield accum.toList + .assertEquals(List(0 -> 0.millis, 1 -> 1.millis, 3 -> 101.millis, 4 -> 230.millis)) + } + + test("respects multiple throttles") { + TestControl + .executeEmbed: + for + (throttlerView, throttledView, getAccum) <- buildViews(100.millis) + _ <- + server(plus(1) -> 20.millis) + .evalMap(throttledView.mod) + .compile + .drain + .background + .use: result => + IO.sleep(10.millis) >> throttlerView.mod(plus(1)) >> + IO.sleep(40.millis) >> throttlerView.mod(plus(1)) >> + result.flatMap(_.embedError) + _ <- IO.sleep(101.millis) // Wait for background threads + accum <- getAccum + yield accum.toList + .assertEquals(List(0 -> 0.millis, 1 -> 10.millis, 2 -> 50.millis, 3 -> 150.millis)) + } diff --git a/modules/tests/shared/src/test/scala/crystal/helpers.scala b/modules/tests/shared/src/test/scala/crystal/helpers.scala new file mode 100644 index 00000000..d9a6a2bd --- /dev/null +++ b/modules/tests/shared/src/test/scala/crystal/helpers.scala @@ -0,0 +1,89 @@ +// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) +// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause + +package crystal + +import cats.Eq +import cats.FlatMap +import cats.data.Chain +import cats.effect.Ref +import cats.effect.SyncIO +import cats.effect.Temporal +import cats.syntax.all.* +import monocle.Iso +import monocle.Lens +import monocle.Optional +import monocle.Traversal +import monocle.macros.GenLens +import monocle.std.option.some + +import scala.concurrent.duration.* + +case class Wrap[A](a: A) { + def map[B](f: A => B): Wrap[B] = Wrap(f(a)) +} +object Wrap { + def a[A]: Lens[Wrap[A], A] = GenLens[Wrap[A]](_.a) + + given [A: Eq]: Eq[Wrap[A]] = Eq.by(_.a) + + def iso[A]: Iso[Wrap[A], A] = Iso[Wrap[A], A](_.a)(Wrap.apply) +} + +case class WrapOpt[A](a: Option[A]) +object WrapOpt { + def a[A]: Lens[WrapOpt[A], Option[A]] = GenLens[WrapOpt[A]](_.a) + + def aOpt[A]: Optional[WrapOpt[A], A] = WrapOpt.a.andThen(some[A].asOptional) + + given [A: Eq]: Eq[WrapOpt[A]] = Eq.by(_.a) +} + +case class WrapList[A](a: List[A]) +object WrapList { + def a[A]: Lens[WrapList[A], List[A]] = GenLens[WrapList[A]](_.a) + + def aList[A]: Traversal[WrapList[A], A] = WrapList.a.andThen(Traversal.fromTraverse[List, A]) + + given [A: Eq]: Eq[WrapList[A]] = Eq.by(_.a) +} + +// Builds a `modCB` function for a `View` backed by a `Ref`. +private def refModCB[F[_]: FlatMap, A](ref: Ref[F, A]): (A => A, (A, A) => F[Unit]) => F[Unit] = + (f, cb) => + ref + .modify[(A, A)]: previous => + val current = f(previous) + (current, (previous, current)) + .flatMap: (previous, current) => + cb(previous, current) + +class AccumulatingViewF[F[_]: Temporal, A] private ( + a: A, + ref: Ref[F, A], + accumRef: Ref[F, Chain[(A, FiniteDuration)]] +) extends ViewF[F, A]( + a, // Should not be used, will stay the same. + (mod, cb) => + refModCB[F, A](ref).apply( + mod, + (previous, current) => + Temporal[F].realTime + .tupleLeft(current) + .flatMap: accum => + accumRef.update(_.append(accum)) >> cb(previous, current) + ) + ): + def accumulated: F[Chain[(A, FiniteDuration)]] = accumRef.get + +object AccumulatingViewF: + def of[F[_]: Temporal, A](value: A): F[AccumulatingViewF[F, A]] = + for + ref <- Ref.of[F, A](value) + accumRef <- Ref.of[F, Chain[(A, FiniteDuration)]](Chain((value, 0.millis))) + yield AccumulatingViewF(value, ref, accumRef) + +object SyncIORefView: + def of[A](value: A): ViewF[SyncIO, A] = + val ref: Ref[SyncIO, A] = Ref.unsafe(value) + new ViewF[SyncIO, A](ref.get.unsafeRunSync(), refModCB(ref)) diff --git a/modules/tests/shared/src/test/scala/crystal/package.scala b/modules/tests/shared/src/test/scala/crystal/package.scala deleted file mode 100644 index 0bbe7823..00000000 --- a/modules/tests/shared/src/test/scala/crystal/package.scala +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2016-2023 Association of Universities for Research in Astronomy, Inc. (AURA) -// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause - -import cats.Eq -import monocle.Iso -import monocle.Lens -import monocle.Optional -import monocle.Traversal -import monocle.macros.GenLens -import monocle.std.option.some - -package crystal { - case class Wrap[A](a: A) { - def map[B](f: A => B): Wrap[B] = Wrap(f(a)) - } - object Wrap { - def a[A]: Lens[Wrap[A], A] = GenLens[Wrap[A]](_.a) - - given [A: Eq]: Eq[Wrap[A]] = Eq.by(_.a) - - def iso[A]: Iso[Wrap[A], A] = Iso[Wrap[A], A](_.a)(Wrap.apply) - } - - case class WrapOpt[A](a: Option[A]) - object WrapOpt { - def a[A]: Lens[WrapOpt[A], Option[A]] = GenLens[WrapOpt[A]](_.a) - - def aOpt[A]: Optional[WrapOpt[A], A] = WrapOpt.a.andThen(some[A].asOptional) - - given [A: Eq]: Eq[WrapOpt[A]] = Eq.by(_.a) - } - - case class WrapList[A](a: List[A]) - object WrapList { - def a[A]: Lens[WrapList[A], List[A]] = GenLens[WrapList[A]](_.a) - - def aList[A]: Traversal[WrapList[A], A] = WrapList.a.andThen(Traversal.fromTraverse[List, A]) - - given [A: Eq]: Eq[WrapList[A]] = Eq.by(_.a) - } -}