Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deglitcher interface for Views #647

Merged
merged 2 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh. At first I read this as View Flaws Spec.

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
Loading