Skip to content

Commit

Permalink
Merge pull request #647 from gemini-hlsw/view-deglitcher
Browse files Browse the repository at this point in the history
Deglitcher interface for Views
  • Loading branch information
rpiaggio authored Sep 6, 2024
2 parents 4f23855 + c10534a commit 797588a
Show file tree
Hide file tree
Showing 14 changed files with 364 additions and 58 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,6 @@ smartgcal
# Node modules
node_modules/
package-lock.json

# Nix
.direnv/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`.
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]] =
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
7 changes: 7 additions & 0 deletions modules/core/js/src/main/scala/crystal/react/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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)
12 changes: 12 additions & 0 deletions modules/core/shared/src/main/scala/crystal/ThrottlingViewF.scala
Original file line number Diff line number Diff line change
@@ -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]
)
82 changes: 82 additions & 0 deletions modules/core/shared/src/main/scala/crystal/ViewThrottlerF.scala
Original file line number Diff line number Diff line change
@@ -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))
}
11 changes: 0 additions & 11 deletions modules/core/shared/src/main/scala/crystal/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

package crystal

import cats.Eq
import cats.Id
import cats.Invariant
import cats.laws.discipline.InvariantSemigroupalTests
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
Loading

0 comments on commit 797588a

Please sign in to comment.